返回 ppt-master
visual_review.py
根目录 / skills / ppt-master / scripts / visual_review.py
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
352 lines PYTHON