Single-file HTML, Canvas 2D, no libraries, no images, no fonts to load.
The one idea worth explaining: the accent colour IS the valence.
moodColor(v) maps v in [-1,1] across cold blue -> neutral grey -> warm amber,
and every mark in the frame reads from it. So when she drops, the whole
picture goes cold -- no caption has to tell you she is sad. Same data,
three places: the trace colour, the memory hue, the retrieval highlight.
Timeline is a pure function render(t), no RAF accumulation. Every frame
renders in isolation, which means frames are reproducible, any resolution
can be re-exported, and the preview can be captured frame-by-frame instead
of screen-recorded.
Four acts:
0-6s membrane matter bounces off it, information passes through
6-12s heartbeat beats with no input; positive recovers fast, negative slow
12-17s memory points sink by salience; one gets pulled back up
by a mood-matching cue; floor at 0.03 so nothing vanishes
17-22s title
Also: ?t=12.5 freezes on that frame (for cover shots), and a REC button
uses canvas.captureStream + MediaRecorder to export webm directly.
Preview: 200 ANMF frames, 22000ms total, verified by parsing the RIFF/ANMF
chunks (Pillow does not expose per-frame WebP duration).
tools/render_preview.py renders frames via headless Chrome and assembles.
On this Windows box Python cannot spawn Chrome (0x80000003, same family as
the 0xC0000142 shell failure), so the render loop was driven from PowerShell
and the script only assembled. Both paths are documented in the script.
209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
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()
|