| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Visual Review Renderer |
| 4 | |
| 5 | Renders project SVGs to 1280x720 PNGs that match the live-preview browser view |
| 6 | (inlined <use data-icon>, resolved <image href>, full font fallback including CJK). |
| 7 | The pure renderer for the visual-review stage — does not edit SVGs, does not |
| 8 | interpret the rubric. |
| 9 | |
| 10 | Backend: Playwright (Chromium). The cairosvg backend was evaluated and rejected |
| 11 | because cairo's text API has no font-fallback chain — CJK characters render as |
| 12 | tofu boxes for any deck whose font-family list relies on system fallback. |
| 13 | |
| 14 | Usage: |
| 15 | python3 scripts/visual_review.py <project_path> |
| 16 | python3 scripts/visual_review.py <project_path> --pages 02 03 |
| 17 | python3 scripts/visual_review.py <project_path> --server-url http://localhost:5050 |
| 18 | |
| 19 | Exit codes (per references/visual-review.md §7): |
| 20 | 0 — all requested pages rendered |
| 21 | 2 — live-preview server not reachable for this project |
| 22 | 3 — rendering backend (playwright + chromium) missing or unable to launch |
| 23 | 4 — one or more page-level render failures (details in stderr) |
| 24 | |
| 25 | Output: JSON summary printed to stdout, PNGs written to <project>/.preview/. |
| 26 | """ |
| 27 | |
| 28 | from __future__ import annotations |
| 29 | |
| 30 | import argparse |
| 31 | import io |
| 32 | import json |
| 33 | import os |
| 34 | import sys |
| 35 | import time |
| 36 | import urllib.error |
| 37 | import urllib.parse |
| 38 | import urllib.request |
| 39 | from contextlib import contextmanager |
| 40 | from pathlib import Path |
| 41 | |
| 42 | from console_encoding import configure_utf8_stdio |
| 43 | from server_common import lock_pid, process_alive, read_lock |
| 44 | from slide_roster import discover_slide_svgs |
| 45 | |
| 46 | configure_utf8_stdio() |
| 47 | |
| 48 | |
| 49 | # Histogram threshold: PNG counts as "all background" if a single quantized |
| 50 | # color bucket holds >= ALL_BG_THRESHOLD of pixels. Guards against blank |
| 51 | # renders without false-firing on legitimate sparse dark layouts. |
| 52 | ALL_BG_THRESHOLD = 0.99 |
| 53 | |
| 54 | |
| 55 | def _safe_print(msg: str) -> None: |
| 56 | print(msg, file=sys.stderr, flush=True) |
| 57 | |
| 58 | |
| 59 | @contextmanager |
| 60 | def file_lock(lock_path: Path, timeout: float = 30.0): |
| 61 | """POSIX advisory lock via fcntl. Falls back to lockless on Windows.""" |
| 62 | try: |
| 63 | import fcntl |
| 64 | except ImportError: |
| 65 | yield |
| 66 | return |
| 67 | |
| 68 | lock_path.parent.mkdir(parents=True, exist_ok=True) |
| 69 | fp = open(lock_path, 'w') |
| 70 | deadline = time.monotonic() + timeout |
| 71 | while True: |
| 72 | try: |
| 73 | fcntl.flock(fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) |
| 74 | break |
| 75 | except BlockingIOError: |
| 76 | if time.monotonic() >= deadline: |
| 77 | fp.close() |
| 78 | raise TimeoutError(f"render lock contended for {timeout}s at {lock_path}") |
| 79 | time.sleep(0.1) |
| 80 | try: |
| 81 | fp.write(str(os.getpid())) |
| 82 | fp.flush() |
| 83 | yield |
| 84 | finally: |
| 85 | fcntl.flock(fp.fileno(), fcntl.LOCK_UN) |
| 86 | fp.close() |
| 87 | try: |
| 88 | lock_path.unlink() |
| 89 | except FileNotFoundError: |
| 90 | pass |
| 91 | |
| 92 | |
| 93 | def is_all_background(png_bytes: bytes) -> bool: |
| 94 | """Histogram check: quantize each channel to 4 bits, count dominant bucket. |
| 95 | Returns True only when the PNG is essentially monochrome (blank render).""" |
| 96 | try: |
| 97 | from PIL import Image |
| 98 | except ImportError: |
| 99 | # PIL not installed — skip this check, the rubric subagent will |
| 100 | # re-validate visually. |
| 101 | return False |
| 102 | |
| 103 | img = Image.open(io.BytesIO(png_bytes)).convert('RGB') |
| 104 | pixels = list(img.getdata()) |
| 105 | total = len(pixels) |
| 106 | if total == 0: |
| 107 | return True |
| 108 | counts: dict[tuple[int, int, int], int] = {} |
| 109 | for r, g, b in pixels: |
| 110 | key = (r >> 4, g >> 4, b >> 4) |
| 111 | counts[key] = counts.get(key, 0) + 1 |
| 112 | dominant = max(counts.values()) |
| 113 | return dominant / total >= ALL_BG_THRESHOLD |
| 114 | |
| 115 | |
| 116 | def fetch_slide_text(server_url: str, page_name: str, timeout: float = 5.0) -> int: |
| 117 | """Probe that the server can return the slide. Returns content length. |
| 118 | Used only for failure detection — the actual fetch happens inside the |
| 119 | browser via fetch() so the response is parsed by JS, not Python.""" |
| 120 | url = f"{server_url.rstrip('/')}/api/slide/{urllib.parse.quote(page_name)}" |
| 121 | req = urllib.request.Request(url, headers={'Accept': 'application/json'}) |
| 122 | with urllib.request.urlopen(req, timeout=timeout) as resp: |
| 123 | payload = json.loads(resp.read().decode('utf-8')) |
| 124 | if 'content' not in payload: |
| 125 | raise RuntimeError(f'unexpected response shape from {url}: {payload!r}') |
| 126 | return len(payload['content']) |
| 127 | |
| 128 | |
| 129 | def render_pages(server_url: str, pages: list[str], preview_dir: Path) -> list[dict]: |
| 130 | """Render all requested pages in a single browser session. |
| 131 | |
| 132 | Each render: page.goto(server_url) anchors the base URL so the SVG's |
| 133 | relative <image href="../images/..."> resolves against the server. |
| 134 | Then fetch the slide via the server's /api/slide endpoint (which inlines |
| 135 | <use data-icon> references) and inject it as the document body. |
| 136 | """ |
| 137 | from playwright.sync_api import sync_playwright |
| 138 | |
| 139 | preview_dir.mkdir(parents=True, exist_ok=True) |
| 140 | records: list[dict] = [] |
| 141 | |
| 142 | inject_js = """ |
| 143 | async (pageName) => { |
| 144 | const res = await fetch('/api/slide/' + encodeURIComponent(pageName) + '?_=' + Date.now()); |
| 145 | if (!res.ok) throw new Error('fetch /api/slide/' + pageName + ' returned ' + res.status); |
| 146 | const data = await res.json(); |
| 147 | document.documentElement.innerHTML = |
| 148 | '<head><style>html,body{margin:0;padding:0;background:#0E1116;overflow:hidden}' |
| 149 | + ' svg{display:block;width:1280px;height:720px}</style></head>' |
| 150 | + '<body>' + data.content + '</body>'; |
| 151 | return { len: data.content.length }; |
| 152 | } |
| 153 | """ |
| 154 | |
| 155 | with sync_playwright() as p: |
| 156 | browser = p.chromium.launch() |
| 157 | try: |
| 158 | context = browser.new_context(viewport={'width': 1280, 'height': 720}) |
| 159 | for page_name in pages: |
| 160 | rec: dict = {'page': page_name, 'ok': False} |
| 161 | try: |
| 162 | fetch_slide_text(server_url, page_name) |
| 163 | except urllib.error.URLError as e: |
| 164 | rec['error'] = f'server_unreachable: {e!r}' |
| 165 | records.append(rec) |
| 166 | continue |
| 167 | except Exception as e: # noqa: BLE001 |
| 168 | rec['error'] = f'{type(e).__name__}: {e}' |
| 169 | records.append(rec) |
| 170 | continue |
| 171 | |
| 172 | stem = page_name[:-4] if page_name.endswith('.svg') else page_name |
| 173 | out_path = preview_dir / f'{stem}.png' |
| 174 | |
| 175 | try: |
| 176 | pg = context.new_page() |
| 177 | pg.goto(server_url, wait_until='domcontentloaded') |
| 178 | pg.evaluate(inject_js, page_name) |
| 179 | # Wait one frame so font/text shaping settles before capture. |
| 180 | pg.wait_for_timeout(100) |
| 181 | png_bytes = pg.screenshot(type='png', full_page=False) |
| 182 | pg.close() |
| 183 | |
| 184 | out_path.write_bytes(png_bytes) |
| 185 | rec['ok'] = True |
| 186 | rec['path'] = str(out_path) |
| 187 | rec['bytes'] = len(png_bytes) |
| 188 | rec['all_background'] = is_all_background(png_bytes) |
| 189 | except Exception as e: # noqa: BLE001 — best-effort per-page |
| 190 | rec['error'] = f'{type(e).__name__}: {e}' |
| 191 | records.append(rec) |
| 192 | finally: |
| 193 | browser.close() |
| 194 | |
| 195 | return records |
| 196 | |
| 197 | |
| 198 | def discover_pages(project_path: Path, requested: list[str] | None) -> list[str]: |
| 199 | svg_dir = project_path / 'svg_output' |
| 200 | if not svg_dir.is_dir(): |
| 201 | raise FileNotFoundError(f'no svg_output/ in {project_path}') |
| 202 | all_svgs = [path.name for path in discover_slide_svgs(svg_dir)] |
| 203 | if not requested: |
| 204 | return all_svgs |
| 205 | selected: list[str] = [] |
| 206 | for token in requested: |
| 207 | match = next((n for n in all_svgs if n.startswith(token) or n == token), None) |
| 208 | if match is None: |
| 209 | raise ValueError(f'no SVG matches token {token!r} in {svg_dir}') |
| 210 | selected.append(match) |
| 211 | return selected |
| 212 | |
| 213 | |
| 214 | def discover_server_url(project_path: Path) -> str: |
| 215 | """Return the live-preview URL recorded for one project.""" |
| 216 | lock_paths = ( |
| 217 | project_path / 'live_preview' / 'lock.json', |
| 218 | project_path / '.live_preview.lock', |
| 219 | ) |
| 220 | for lock_path in lock_paths: |
| 221 | lock = read_lock(lock_path) |
| 222 | if not lock or not process_alive(lock_pid(lock)): |
| 223 | continue |
| 224 | try: |
| 225 | port = int(lock.get('port', 0) or 0) |
| 226 | except (TypeError, ValueError): |
| 227 | port = 0 |
| 228 | if 1 <= port <= 65535: |
| 229 | return f'http://127.0.0.1:{port}' |
| 230 | raise RuntimeError( |
| 231 | f'no running live-preview server recorded for project: {project_path}' |
| 232 | ) |
| 233 | |
| 234 | |
| 235 | def check_server(server_url: str, project_path: Path) -> None: |
| 236 | """Require a live-preview server that belongs to the target project.""" |
| 237 | url = f"{server_url.rstrip('/')}/api/health" |
| 238 | try: |
| 239 | with urllib.request.urlopen(url, timeout=3.0) as resp: |
| 240 | if resp.status != 200: |
| 241 | raise RuntimeError(f'{url} returned HTTP {resp.status}') |
| 242 | data = json.load(resp) |
| 243 | except (urllib.error.URLError, OSError, ValueError) as e: |
| 244 | raise RuntimeError(f'live-preview server not reachable at {server_url}: {e}') |
| 245 | expected_project = str(project_path) |
| 246 | expected_svg_output = str((project_path / 'svg_output').resolve()) |
| 247 | service = data.get('service') if isinstance(data, dict) else None |
| 248 | legacy_live_preview = ( |
| 249 | service is None |
| 250 | and isinstance(data, dict) |
| 251 | and data.get('svg_output') == expected_svg_output |
| 252 | ) |
| 253 | if ( |
| 254 | not isinstance(data, dict) |
| 255 | or data.get('project') != expected_project |
| 256 | or (service != 'live_preview' and not legacy_live_preview) |
| 257 | ): |
| 258 | raise RuntimeError( |
| 259 | f'URL does not belong to this project live preview: {server_url}' |
| 260 | ) |
| 261 | |
| 262 | |
| 263 | def main() -> int: |
| 264 | parser = argparse.ArgumentParser( |
| 265 | description='Render project SVGs to PNGs for visual review.', |
| 266 | ) |
| 267 | parser.add_argument('project_path', help='Path to project directory (contains svg_output/)') |
| 268 | parser.add_argument( |
| 269 | '--pages', nargs='+', default=None, |
| 270 | help='Page tokens to render (default: all SVGs in svg_output/). ' |
| 271 | "Accepts '02', '02_three_steps', or '02_three_steps.svg'.", |
| 272 | ) |
| 273 | parser.add_argument( |
| 274 | '--server-url', default=None, |
| 275 | help='Explicit live-preview URL (default: discover it from the project lock)', |
| 276 | ) |
| 277 | parser.add_argument( |
| 278 | '--lock-timeout', type=float, default=30.0, |
| 279 | help='Seconds to wait for render lock (default: 30)', |
| 280 | ) |
| 281 | args = parser.parse_args() |
| 282 | |
| 283 | project_path = Path(args.project_path).resolve() |
| 284 | if not project_path.is_dir(): |
| 285 | _safe_print(f'project path not found: {project_path}') |
| 286 | return 2 |
| 287 | |
| 288 | try: |
| 289 | from playwright.sync_api import sync_playwright # noqa: F401 |
| 290 | except ImportError: |
| 291 | _safe_print( |
| 292 | 'playwright not installed. Install with:\n' |
| 293 | ' pip install playwright\n' |
| 294 | ' python3 -m playwright install chromium\n' |
| 295 | '(see skills/ppt-master/requirements.txt)' |
| 296 | ) |
| 297 | return 3 |
| 298 | |
| 299 | try: |
| 300 | server_url = args.server_url or discover_server_url(project_path) |
| 301 | check_server(server_url, project_path) |
| 302 | except RuntimeError as e: |
| 303 | _safe_print(str(e)) |
| 304 | _safe_print( |
| 305 | 'start it with:\n' |
| 306 | f' python3 skills/ppt-master/scripts/svg_editor/server.py {project_path}' |
| 307 | ) |
| 308 | return 2 |
| 309 | |
| 310 | try: |
| 311 | pages = discover_pages(project_path, args.pages) |
| 312 | except (FileNotFoundError, ValueError) as e: |
| 313 | _safe_print(str(e)) |
| 314 | return 2 |
| 315 | |
| 316 | preview_dir = project_path / '.preview' |
| 317 | lock_path = preview_dir / '.render.lock' |
| 318 | |
| 319 | with file_lock(lock_path, timeout=args.lock_timeout): |
| 320 | try: |
| 321 | records = render_pages(server_url, pages, preview_dir) |
| 322 | except Exception as e: # noqa: BLE001 — browser launch failure |
| 323 | _safe_print(f'browser session failed: {type(e).__name__}: {e}') |
| 324 | _safe_print( |
| 325 | 'try: python3 -m playwright install chromium' |
| 326 | ) |
| 327 | return 3 |
| 328 | |
| 329 | for rec in records: |
| 330 | if not rec['ok']: |
| 331 | _safe_print(f"[FAIL] {rec['page']}: {rec.get('error')}") |
| 332 | elif rec.get('all_background'): |
| 333 | _safe_print(f"[WARN] {rec['page']}: PNG rendered but is all-background") |
| 334 | |
| 335 | summary = { |
| 336 | 'project': str(project_path), |
| 337 | 'server_url': server_url, |
| 338 | 'rendered': sum(1 for r in records if r['ok']), |
| 339 | 'failed': sum(1 for r in records if not r['ok']), |
| 340 | 'all_background': sum(1 for r in records if r.get('all_background')), |
| 341 | 'pages': records, |
| 342 | } |
| 343 | print(json.dumps(summary, indent=2, ensure_ascii=False)) |
| 344 | |
| 345 | if summary['failed']: |
| 346 | return 4 |
| 347 | return 0 |
| 348 | |
| 349 | |
| 350 | if __name__ == '__main__': |
| 351 | sys.exit(main()) |
| 352 |