r"""把 docs/intro.html 渲染成动画预览图。
做法:用 headless Chrome 打开 `intro.html?t=<秒>`,逐帧截图,
再用 Pillow 拼成动画 WebP。
之所以可行,是因为动画的时间轴是纯函数 render(t)——
任意一帧都能独立渲染,不需要真实播放,也不会有录制抖动。
用法:
python tools/render_preview.py # 默认 10fps / 800x450
python tools/render_preview.py --fps 15 --width 1200
python tools/render_preview.py --stills # 只导出关键帧静图
依赖:Pillow(pip install pillow)
Windows 注意:
在某些环境下,从 Python 里 spawn Chrome 会以 0x80000003 直接退出
(从 PowerShell 直接调同样的命令却正常)。遇到这种情况,用
`--assemble-only` 跳过渲染,改在 PowerShell 里逐帧截图:
$chrome = "C:\Program Files\Google\Chrome\Application\chrome.exe"
$url = ([uri]"$PWD\\docs\\intro.html").AbsoluteUri
for ($i=0; $i -lt 220; $i++) {
$f = "._frames\\f{0:d4}.png" -f $i
& $chrome --headless=new --disable-gpu --window-size=800,450 `
--virtual-time-budget=1200 "--screenshot=$PWD\\$f" `
"$url`?t=$([math]::Round($i/10.0,3))"
}
然后再跑 `--assemble-only`。
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.exit("需要 Pillow:pip install pillow")
REPO = Path(__file__).resolve().parents[1]
INTRO = REPO / "docs" / "intro.html"
CHROME_CANDIDATES = [
os.environ.get("CHROME_PATH", ""),
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
]
#: 关键帧:每一幕的最佳瞬间
STILLS = {
"01-membrane.png": 3.2,
"02-heartbeat.png": 11.4,
"03-memory.png": 15.8,
"04-title.png": 19.8,
}
def find_chrome() -> str:
for path in CHROME_CANDIDATES:
if path and Path(path).exists():
return path
sys.exit("找不到 Chrome/Edge;设 CHROME_PATH 环境变量指向可执行文件")
def file_url(t: float) -> str:
return INTRO.as_uri() + f"?t={t:.3f}"
def shoot(chrome: str, t: float, out: Path, w: int, h: int, profile: Path) -> Path:
"""渲染单帧。"""
cmd = [
chrome,
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
"--no-first-run",
"--no-default-browser-check",
f"--user-data-dir={profile}",
f"--window-size={w},{h}",
"--virtual-time-budget=1500",
f"--screenshot={out}",
file_url(t),
]
subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
check=False,
)
if not out.exists():
raise RuntimeError(f"截图失败: t={t}")
return out
def render_frames(
chrome: str, duration: float, fps: float, w: int, h: int, outdir: Path, workers: int
) -> list[Path]:
n = int(round(duration * fps))
times = [i / fps for i in range(n)]
outdir.mkdir(parents=True, exist_ok=True)
def work(i: int) -> Path:
profile = Path(tempfile.mkdtemp(prefix=f"cg-{i}-"))
try:
return shoot(chrome, times[i], outdir / f"f{i:04d}.png", w, h, profile)
finally:
import shutil
shutil.rmtree(profile, ignore_errors=True)
print(f"渲染 {n} 帧 @ {fps}fps,{workers} 并发 …")
paths: list[Path] = [None] * n # type: ignore[list-item]
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
for i, p in zip(range(n), pool.map(work, range(n)), strict=True):
paths[i] = p
done += 1
if done % 20 == 0 or done == n:
print(f" {done}/{n}")
return paths
def assemble(paths: list[Path], out: Path, fps: float, quality: int) -> None:
frames = [Image.open(p).convert("RGB") for p in paths]
frames[0].save(
out,
save_all=True,
append_images=frames[1:],
duration=int(round(1000 / fps)),
loop=0,
quality=quality,
method=6,
)
size_mb = out.stat().st_size / 1024 / 1024
print(f"写出 {out.relative_to(REPO)} ({size_mb:.2f} MB, {len(frames)} 帧)")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--fps", type=float, default=10)
ap.add_argument("--width", type=int, default=800)
ap.add_argument("--height", type=int, default=450)
ap.add_argument("--duration", type=float, default=22.0)
ap.add_argument("--workers", type=int, default=6)
ap.add_argument("--quality", type=int, default=82)
ap.add_argument("--out", default="docs/preview.webp")
ap.add_argument("--frames-dir", default=".frames")
ap.add_argument("--stills", action="store_true", help="只导出关键帧静图")
ap.add_argument(
"--assemble-only",
action="store_true",
help="跳过渲染,直接把 --frames-dir 里已有的 PNG 拼成动图",
)
ap.add_argument("--keep-frames", action="store_true")
args = ap.parse_args()
if args.assemble_only:
work = REPO / args.frames_dir
paths = sorted(work.glob("f*.png"))
if not paths:
sys.exit(f"{work} 里没有帧")
assemble(paths, REPO / args.out, args.fps, args.quality)
return
chrome = find_chrome()
work = REPO / args.frames_dir
work.mkdir(parents=True, exist_ok=True)
if args.stills:
for name, t in STILLS.items():
profile = Path(tempfile.mkdtemp(prefix="cg-s-"))
try:
out = REPO / "docs" / name
shoot(chrome, t, out, args.width, args.height, profile)
print(f"写出 {out.relative_to(REPO)}")
finally:
import shutil
shutil.rmtree(profile, ignore_errors=True)
return
paths = render_frames(
chrome, args.duration, args.fps, args.width, args.height, work, args.workers
)
assemble(paths, REPO / args.out, args.fps, args.quality)
if not args.keep_frames:
import shutil
shutil.rmtree(work, ignore_errors=True)
if __name__ == "__main__":
main()