From 8a802d4b796c2782822bc9d1348ebba534f249b9 Mon Sep 17 00:00:00 2001 From: zhanglinghao Date: Mon, 19 Oct 2026 00:18:33 +0800 Subject: [PATCH] Long renders: one-GOP chunks taken in turn, a stopped or failed render resumes, an estimate first bin/vh render split the frames evenly across its workers, so one heavy chapter held the whole render up, and a late frame timeout threw a long 4K render away; the way round (range renders joined by hand) left no sidecar for bin/vh patch. - Chunks of one GOP each, taken by the --workers browsers as they finish one, each into its own x264: the video no longer depends on --workers. GOP grid, whole-GOP concat (now with each chunk's exact length) and the sidecar stay compatible. - A stopped or failed render keeps its finished chunks (..parts/plan.json records the page, the sha256 of every file served, the options, browser, ffmpeg and tools); the same command resumes, anything changed starts over. - Spans over 600 frames (or --estimate) are estimated first: time per frame, time with the workers, size range and peak disk, refused when the disk cannot hold it; --no-estimate skips it (patch does). - --fps unlike the page's own (new optional window.film.fps; canvas-film's FILM.fps) is said; a relative --out outside the project is printed in full. - Docs (help, bin/vh, engines/README, CONTRACT), CHANGELOG, CI cases on a new uneven-cost fixture and the canvas-film template. --- CHANGELOG.md | 10 + bin/vh | 12 +- engines/CONTRACT.md | 1 + engines/README.md | 6 + engines/canvas-film/template/src/90-main.js | 1 + tools/ci.sh | 36 +++ tools/film/fixture/uneven.html | 25 +++ tools/film/host.mjs | 14 +- tools/film/patch.mjs | 2 +- tools/film/render.mjs | 236 +++++++++++++++----- 10 files changed, 285 insertions(+), 58 deletions(-) create mode 100644 tools/film/fixture/uneven.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d458d63..88229584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +**Long renders: one-GOP chunks taken in turn, a stopped or failed render resumes, an estimate before a long one; `--fps` against the page's own; where a relative `--out` goes** +- Why: `bin/vh render` split the frames evenly by count across its workers, so one heavy chapter (3D at 4K) kept one worker busy long after the others were done, and a frame that timed out late in a long 4K render threw the whole render away. The way round was to render the film as two `--range` runs and join them by hand, which leaves no sidecar, so `bin/vh patch` could not be used on the result. +- The span is now cut into chunks of one GOP each, and the `--workers` browsers take the next chunk as they finish one. Each chunk goes into an x264 of its own, so the video no longer depends on `--workers` or on which browser drew what: 1, 2 and 4 workers give the same decoded frames, where before each worker count gave other bytes. The keyframe grid, the concat of whole-GOP chunks that patch cuts on, and the sidecar are as before (`chunks` now lists one per GOP; new `reused_chunks`). The join gives each chunk its exact length, as patch's join does. The frames drawn are the same as before. The encoded bytes are the same as an earlier render that also had one GOP per chunk; where it had several GOPs per chunk they differ at x264's rate-control level (54 dB PSNR on the canvas-film template, which was cut into 4 chunks of 3 GOPs). +- Stopped (Ctrl-C, SIGTERM) or failed (a frame past `--frame-timeout`, an encoder that stopped), a render keeps the chunks it finished in `..parts/`. `plan.json` there records what they were made from: the page and the sha256 of every file the browsers were sent, the options that shape the video (all but `--workers`, `--frame-timeout` and the estimate), the browser, ffmpeg and the render tools. The same command again draws only the rest; when any of that changed, it names what changed and starts over. A chunk in which the page reported an error is never kept. After a failure, the other workers finish the chunk they are on first. With this and the queue, neither reason for hand-joined range renders is left, so no join command was added. +- The estimate: a span of more than 600 frames to draw (any span with `--estimate`) first draws six places of three consecutive frames in one browser and encodes them with the film's settings. It prints the time a frame takes, the time with the workers, the video's size as a range, and the disk needed at the peak, and stops (exit 2) when even the low end does not fit. About a second for the template at 1080p; `--no-estimate` skips it, and `bin/vh patch` skips it. On the canvas-film template it estimated 9 s and took 11 s, and gave 6–12 MB for a 9.0 MB video at 60 fps and 10–19 MB for 9.3 MB at 30 fps. +- `--fps` other than the page's own frame rate is said, naming both. The page contract has an optional `fps` field (`engines/CONTRACT.md`). The canvas-film template gives `FILM.fps` there; for a copy made before that, render reads `FILM.fps` directly. +- A relative `--out` stays relative to the current folder, as the output paths of the other tools are (frames, textcheck, storyboard, deliver, mux). When that puts the video outside the project (bin/vh run from the harness with `--out out/x.mp4`), the full path is printed at the start and at the end. +- `bin/vh render -h`, the tool's own help and `engines/README.md` (canvas-film) describe all this. +- Regression coverage: a page whose last second is slow, with one very slow frame, renders with 3 workers to the video 1 worker makes (PSNR). With `--frame-timeout 1` it keeps 5 of 6 chunks; after an edit to the page the next run starts over; the same command then resumes and gives the whole render's video. The template at `--fps 60`, run from another folder with a relative `--out`, prints the `--out` note, the fps note and the estimate, and is refused on a disk with room only for a flat film. + **Confirmed alerts: a project's confirmed.json for detector findings that are intended** - Why: a detector judges by thresholds, and some findings are intended (a one-frame flash the storyboard asks for, a sparse passage written into the score). With no way to say so that lasts, every `check` repeated them, `--allow` only covered one run, and projects changed the film to quiet the report. Floors stay out of it. - `/confirmed.json` (`tools/confirm.py`): an entry names the finding's time or span, why it is intended, the project file that shows it, and the key the tool printed, a coarse fingerprint of the picture or the sound there. `bin/vh check`'s isolated-frame scan (`check.iso`) and `bin/vh qa`'s scan (`qa.dropout`, `qa.pump`, `qa.click` over a span with how many) read it. A confirmed finding is still listed, marked confirmed (已确认), and no longer fails; when the picture or the sound there changes the confirmation lapses (已失效) and the finding fails again; one with nothing flagged at its time any more is listed as unused. Each open finding gets a `confirm?` line to paste. Entries with no why, evidence that is not a regular file inside the project (a folder, `../`, a link out of it), or a floor's kind (digital silence, the reading floor, the gates, the spec) are listed as not taken; cue-check OFFs and the motion warnings are not confirmable. An unreadable file fails the check rather than skipping it. Five or more confirmations of one kind in a film add a note that the detector itself may be wrong. diff --git a/bin/vh b/bin/vh index e140c424..767f168b 100755 --- a/bin/vh +++ b/bin/vh @@ -90,9 +90,15 @@ # --covers: the page's covers (window.film.covers; canvas-film COVERS, picked by cue) in /out/covers # (--no-title: without their titles, -clean.png; --ratios: each ratio a crop of the cover with its own title, -9x16.png) # render [--out f.mp4] [--fps 30] [--range a:b | --frames k0:k1] [--dpr 2] [--workers 4] [--crf 16] [--gop 60] [--png] +# [--estimate | --no-estimate] # an MP4 drawn through the page contract (the canvas engine, ClaudeAnimationBase, any page that writes -# window.film): parallel chunks into x264, BT.709 tv range, a keyframe every --gop frames, .render.json -# records how; video only (sound: mux). HyperFrames: npx hyperframes render (--force to render it here anyway). +# window.film): one-GOP chunks that the --workers browsers take in turn, each into its own x264, BT.709 tv +# range, a keyframe every --gop frames, .render.json records how; video only (sound: mux). Stopped or +# failed, the finished chunks stay: the same command resumes while the page, its files and the options are +# unchanged. Over 600 frames it first draws a few and prints the time, size and disk the render needs +# (refused when the disk cannot hold it; --no-estimate skips). Says when --fps is not the page's own +# (window.film.fps). A relative --out is from the current folder. HyperFrames: npx hyperframes render +# (--force to render it here anyway). # A whole standard or studio film waits for gate ① on record (tools/gatecheck.py); --range and --frames do not # patch (--frames a-b | --seconds s-e) [--sidecar f.render.json] [--out f | --in-place] [--workers 4] [--verify seams|full] # draw frames a–b of a bin/vh render film again and put them back, the rest untouched: cut on its keyframes @@ -594,7 +600,7 @@ arg_dir() { # the folder of the project or page among a tool's arguments: the fi for a in "$@"; do if [ $skip = 1 ]; then skip=0; continue; fi case $a in - --soft-gl|--real-clock|--png|--force|--save|--export|--strict|--in-place|--keep|--snapshot|--*=*) ;; + --soft-gl|--real-clock|--png|--force|--save|--export|--strict|--in-place|--keep|--snapshot|--estimate|--no-estimate|--*=*) ;; -*) skip=1 ;; *) if [ -d "$a" ]; then echo "$a"; else case $a in */*) echo "${a%/*}" ;; *) echo . ;; esac; fi; return 0 ;; esac diff --git a/engines/CONTRACT.md b/engines/CONTRACT.md index f9e110bd..4d71f802 100644 --- a/engines/CONTRACT.md +++ b/engines/CONTRACT.md @@ -39,6 +39,7 @@ window.film = { | `timeline` | 这些 cue 挂在哪份旁白时间轴上:相对页面的路径(`audio/timeline.zh.json`),没有旁白时是 `null` | `bin/vh cues` 记下它的 sha256,认出换过的配音 | | `music` | `{path, sha256}`,相对页面的配乐 beats 文件路径,以及页面实际加载字节的 SHA-256;无配乐时为 `null` | `bin/vh cues --save` 保存来源,重跑时提示配乐表变化 | | `timemap` | 可选。`{path, sha256}`,相对页面的时间映射文件(数据轴的折点和事件,`tools/audio/time_map.py` 说明格式),以及页面实际加载字节的 SHA-256;没有时为 `null` 或不提供 | `bin/vh cues --save` 保存来源,重跑时提示映射变化 | +| `fps` | 可选。页面是按哪个帧率做的(数字):每帧变一次的东西(颗粒、抖动)按它变 | `bin/vh render`:`--fps` 和它不同时提醒一句,两个数都写出来 | | `ui(t)` | 方法:t 秒时常驻界面(进度条、署名、字幕)的字和线在哪:`[{name, box: [x0, y0, x1, y1]}]`,画面的 CSS px;只框界面自己的字和线,垫在下面的底板、渐变不算。写了它,`frame(t, { layer: "scene" })` 就要画出不带界面的这一帧(不认这个参数的页面照常画整帧,宿主会看出来、说一声) | `bin/vh textcheck`:两种画法各画一遍,场景自己在这些框里画了线、边、字的,报出来(警告) | 这几项都是数据,在 `ready` 里算好:OpenFilm 的说法是"跟着声音走的时间是数据,不是接口"。`cover(id)` 和 `ui(t)` 是方法,同样是 t 的纯函数。 diff --git a/engines/README.md b/engines/README.md index a5a40035..21cb87bc 100644 --- a/engines/README.md +++ b/engines/README.md @@ -34,6 +34,12 @@ bin/vh frames projects/- --at 1,5,9 # 模板带一支 23 秒的示 - 一页 `film.html` 加 `src/*.js`,不用构建。它按页面合约写了 `window.film`,`bin/vh frames` 取帧、`bin/vh textcheck` 查字直接能用;reads 和字幕(`texts`)、画面事件(`events`)、章节和 cue 表都在 `ready` 里算好。 - 字号下限在画字的最底层兜住,故意要小的字写 `texture: true`;绕过它写下的小字,`ready` 时会被报出来。 - 成片画面用 `bin/vh render <项目>`(4K 加 `--dpr 2`),再用 `bin/vh mux` 套上混音。出片后只改了一镜,用 `bin/vh patch <成片> --seconds <起>-<止>` 只重画那一段、在关键帧处接回去(套过混音的成片加 `--sidecar out/film.mp4.render.json`)。现在只有 2D canvas,没有 Three.js 底层。 +- 长片的 `bin/vh render`(也适用于别的写了 `window.film` 的页面): + - 片子按 GOP 切段,一个 GOP 一段;`--workers` 个浏览器谁画完谁取下一段,重的一章不会让别的 worker 干等。每段单独进一个 x264,所以成片和 `--workers` 是几无关,sidecar 照旧,`bin/vh patch` 照旧能用。 + - 停下(Ctrl-C)或失败(比如一帧超过 `--frame-timeout`)时,画完的段留在视频旁边的 `.<视频名>.parts/` 里,原样再跑同一条命令只画剩下的。页面和它载入的每个文件、决定成片的参数(`--workers`、`--frame-timeout` 和估算之外的都算)、浏览器和渲染工具本身都没变,才接着用;变了会说哪里变了,从头渲。所以不用再分几段 `--range` 渲完手拼:手拼的成片没有一份完整的 sidecar,`bin/vh patch` 用不上。 + - 要画的帧超过 600 帧时,先在片中均匀取几帧画出来、按成片的设置编码,打印每帧耗时、预计用时、视频大概多大、渲染时磁盘最多要多少(各段加合并出的那份,约两倍),磁盘放不下就停(exit 2)。几秒钟;`--no-estimate` 跳过,`--estimate` 让短片也估。用时是按一个浏览器的速度除以 worker 数算的,几个浏览器共用一块 GPU 或机器忙时会更久。 + - 页面给出 `window.film.fps` 时(模板给的是 `FILM.fps`,颗粒每帧换一次),`--fps` 和它不一样会提醒,两个数都写出来;改帧率时两处一起改。之前复制的项目没有这个字段,`render` 直接读 `FILM.fps`,不用改。 + - `--out` 的相对路径和别的 `bin/vh` 命令一样,从当前目录算起。在仓库根目录跑 `--out out/x.mp4`,视频会在仓库的 `out/` 里,不在项目里,这时会把完整路径打出来;要放进项目,写项目里的路径,或者不写 `--out`(默认 `<项目>/out/<页面名>.mp4`)。 ## HyperFrames(HTML + GSAP) diff --git a/engines/canvas-film/template/src/90-main.js b/engines/canvas-film/template/src/90-main.js index 58dca285..645bb820 100644 --- a/engines/canvas-film/template/src/90-main.js +++ b/engines/canvas-film/template/src/90-main.js @@ -191,6 +191,7 @@ window.film = { ready: READY, frame, get duration() { return DUR; }, + get fps() { return FILM.fps; }, // the rate it is made for (bin/vh render says when --fps differs) get shots() { // actual cut boundaries, resolved by ready return SHOT_T.map((S) => { const s = S.shot, chapter = CHAPTER_T.filter((c) => c.t <= S.a).at(-1); diff --git a/tools/ci.sh b/tools/ci.sh index 5333386f..47ddf14c 100755 --- a/tools/ci.sh +++ b/tools/ci.sh @@ -1149,6 +1149,42 @@ PY a=$(OVH_FREE_BYTES=1000 vh render tools/film/fixture --out "$t/r/full.mp4" 2>&1); rc=$? case "$rc:$a" in 2:*"needed at the peak"*"GB free on that disk"*) ok "render: a disk too full for even a flat film stops it before a frame is drawn (exit 2)" ;; *) bad "render on a full disk: exit $rc: $(printf '%s' "$a" | tail -c 400)" ;; esac + # the queue and the resume, on a page whose frames cost unevenly (tools/film/fixture/uneven.html: from 2 s on 30 ms + # each, frame 75 1.5 s): chunks of one GOP, so 3 workers make the video 1 makes (PSNR: separate browsers). A render + # whose frame 75 passes --frame-timeout 1 keeps the 5 chunks it finished; with the page edited they are not used; + # the same command then draws only the last chunk, and the video is the one a whole render makes + mkdir -p "$t/q" && cp tools/film/fixture/uneven.html "$t/q/u.html" + vh render "$t/q/u.html" --workers 1 --gop 15 --out "$t/q/one.mp4" >/dev/null 2>&1 + a=$(vh render "$t/q/u.html" --workers 3 --gop 15 --out "$t/q/three.mp4" 2>&1); rc=$? + b=$(vh render "$t/q/u.html" --workers 2 --gop 15 --frame-timeout 1 --out "$t/q/r.mp4" 2>&1); rc2=$? + printf '\n' >> "$t/q/u.html" + c=$(vh render "$t/q/u.html" --workers 2 --gop 15 --frame-timeout 1 --out "$t/q/r.mp4" 2>&1); rc3=$? + d=$(vh render "$t/q/u.html" --workers 2 --gop 15 --out "$t/q/r.mp4" 2>&1); rc4=$? + if [ "$rc:$rc2:$rc3:$rc4" = 0:1:1:0 ] && printf '%s' "$b" | grep -q '5 of 6 chunks are kept' && printf '%s' "$c" | grep -q 'not used (changed: .*u\.html)' \ + && printf '%s' "$d" | grep -q 'resuming: 5 of 6 chunks kept' && [ ! -e "$t/q/.r.mp4.parts" ] && python3 - "$t/q" <<'PY' +import json, re, subprocess, sys +d = sys.argv[1] +def psnr(a, b): + r = subprocess.run(["ffmpeg", "-v", "info", "-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"], capture_output=True, text=True).stderr + m = re.findall(r"average:(inf|[\d.]+)", r) + return float(m[-1]) if m else 0 +side = json.load(open(d + "/r.mp4.render.json")) +ok = side["chunks"] == [[k, k + 15] for k in range(0, 90, 15)] and side["reused_chunks"] == 5 and side["frames"] == 90 +sys.exit(0 if ok and psnr(d + "/one.mp4", d + "/three.mp4") >= 45 and psnr(d + "/one.mp4", d + "/r.mp4") >= 45 else 1) +PY + then ok "render: one-GOP chunks taken in turn (3 workers make what 1 makes); a timed-out frame keeps the finished chunks, an edited page starts over, the same command resumes" + else bad "render queue and resume: exit $rc:$rc2:$rc3:$rc4: $(printf '%s' "$b$c$d" | tail -c 900)"; fi + # the estimate, the page's own fps and a relative --out, from another folder: the canvas-film template (made for 30 fps) + # at --fps 60 is over 600 frames, so it is estimated; the disk has room for a flat film (7.2 MB at the peak) but not + # for what was measured (about 12 MB) + mkdir -p "$t/e" + a=$(cd "$t/e" && OVH_FREE_BYTES=8000000 "$VH_BASH" "$ROOT/bin/vh" render "$ROOT/engines/canvas-film/template" --fps 60 --out rel/f.mp4 2>&1); rc=$? + case "$rc:$a" in + 2:*"--out rel/f.mp4 is from the current folder: "*"/e/rel/f.mp4, outside the project"*"drawn at 60 fps (--fps), the page says 30"*"estimate ("*" frames in about"*"is needed at the peak and 8 MB is free"*) + [ ! -e "$t/e/rel/.f.mp4.parts" ] && ok "render: a long span is estimated first and refused on a disk that cannot hold it; --fps unlike the page's and an --out outside the project are said" \ + || bad "render: the refused estimate left $t/e/rel/.f.mp4.parts behind" ;; + *) bad "render estimate, fps and --out (exit $rc): $(printf '%s' "$a" | tail -c 900)" ;; + esac # bin/vh patch: the fixture rendered from 0.5 s with a keyframe every 20 frames (not a whole number of milliseconds) in # a folder whose name a filter graph would choke on; then its square turned blue from 1.4 s up to 1.7 s (the film's # frames 27–35) and those frames put back: 20–39 drawn again, the rest the film's own bytes and times. Naming only 27–29 diff --git a/tools/film/fixture/uneven.html b/tools/film/fixture/uneven.html new file mode 100644 index 00000000..2e4745f0 --- /dev/null +++ b/tools/film/fixture/uneven.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/tools/film/host.mjs b/tools/film/host.mjs index 73992d23..75d0febc 100644 --- a/tools/film/host.mjs +++ b/tools/film/host.mjs @@ -55,19 +55,22 @@ const TYPES = { ".html": "text/html; charset=utf-8", ".htm": "text/html; charset ".m4a": "audio/mp4", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".wasm": "application/wasm", ".glsl": "text/plain", ".txt": "text/plain", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json" }; -// the page's folder on 127.0.0.1; the page itself with `inject` before ; /__vh/ from `extra`; no path out of the folder -function serve(root, pageRel, inject, extra) { +// the page's folder on 127.0.0.1; the page itself with `inject` before ; /__vh/ from `extra`; no path out of the folder. +// `served` collects the path of every file sent (bin/vh render resumes only while they are unchanged) +function serve(root, pageRel, inject, extra, served) { const server = http.createServer((req, res) => { let rel; try { rel = decodeURIComponent(new URL(req.url, "http://x").pathname); } catch { res.writeHead(400); return res.end(); } if (rel.startsWith("/__vh/") && Object.hasOwn(extra, rel.slice(6))) { res.writeHead(200, { "content-type": "text/javascript", "cache-control": "no-store" }); + served.add(extra[rel.slice(6)]); return fs.createReadStream(extra[rel.slice(6)]).pipe(res); } if (rel === "/" + pageRel && inject) { const html = fs.readFileSync(path.join(root, pageRel), "utf8"); const i = html.search(/<\/head>/i); res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + served.add(path.join(root, pageRel)); return res.end(i >= 0 ? html.slice(0, i) + inject + html.slice(i) : inject + html); } const f = path.join(root, rel); @@ -75,13 +78,14 @@ function serve(root, pageRel, inject, extra) { try { ok = f.startsWith(root + path.sep) && fs.statSync(f).isFile(); } catch { /* missing */ } if (!ok) { res.writeHead(404); return res.end(); } res.writeHead(200, { "content-type": TYPES[path.extname(f).toLowerCase()] || "application/octet-stream", "cache-control": "no-store" }); + served.add(f); fs.createReadStream(f).pipe(res); }); return new Promise((ok) => server.listen(0, "127.0.0.1", () => ok({ url: `http://127.0.0.1:${server.address().port}/`, close: () => new Promise((r) => server.close(r)) }))); } // openFilm(target, { browser, dpr, width, height, realClock, softGl, runtime, prelude }) → a film to draw: { page, kind, -// width, height, duration, frame(t) → { png, touched }, draw(t), problems(), close(), ev, send }. runtime: "auto" +// width, height, duration, frame(t) → { png, touched }, draw(t), problems(), served(), close(), ev, send }. runtime: "auto" // (HyperFrames' runtime for a composition that writes no contract of its own), "on" or "off". prelude: more scripts to // run before the page's own, after the clock (tools/film/glyphs.mjs records the text a page draws this way). export async function openFilm(target, o = {}) { @@ -96,7 +100,8 @@ export async function openFilm(target, o = {}) { extra["hf-runtime.js"] = rt; extra["hf-adapter.js"] = path.join(HERE, "hf-adapter.js"); scripts.push('', ''); } - const server = await serve(root, pageRel, scripts.join("\n"), extra); + const served = new Set(); + const server = await serve(root, pageRel, scripts.join("\n"), extra, served); const flags = o.softGl ? ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"] : process.platform === "darwin" ? ["--use-angle=metal", "--ignore-gpu-blocklist"] : ["--ignore-gpu-blocklist"]; if (process.platform === "linux") flags.push("--no-sandbox", "--disable-dev-shm-usage"); @@ -222,6 +227,7 @@ export async function openFilm(target, o = {}) { return { png, touched, reply, width: pw, height: ph }; }, problems: () => problems.splice(0), + served: () => [...served], // the files sent to the page so far (absolute paths) // the page's own fields (texts, events, chapters, cues, timeline: engines/CONTRACT.md); list(name) only takes an array field, list: async (name) => { const x = await field(name); return Array.isArray(x) ? x : null; }, diff --git a/tools/film/patch.mjs b/tools/film/patch.mjs index 676f6c55..c3fcadf3 100644 --- a/tools/film/patch.mjs +++ b/tools/film/patch.mjs @@ -152,7 +152,7 @@ for (const [sig, code] of [["SIGINT", 130], ["SIGTERM", 143]]) process.once(sig, const piece = path.join(work, "piece.mp4"); const capture = String(side.capture || "jpeg q95"), q = capture.match(/jpeg q(\d+)/); const rargs = [path.join(HERE, "render.mjs"), page, "--browser", browser, "--frames", `${k0}:${k1}`, "--fps", String(fps), "--dpr", String(side.dpr || 1), - "--crf", String(enc.crf), "--preset", String(enc.preset), "--gop", String(gop), "--workers", String(o.workers), "--out", piece, + "--crf", String(enc.crf), "--preset", String(enc.preset), "--gop", String(gop), "--workers", String(o.workers), "--out", piece, "--no-estimate", ...(capture === "png" ? ["--png"] : ["--quality", q ? q[1] : "95"]), ...(side.real_clock ? ["--real-clock"] : []), ...(side.soft_gl ? ["--soft-gl"] : []), ...(side.kind === "hyperframes" ? ["--force"] : [])]; const T0 = Date.now(); const code = await new Promise((ok) => { diff --git a/tools/film/render.mjs b/tools/film/render.mjs index 1dbbf509..13fb9d84 100644 --- a/tools/film/render.mjs +++ b/tools/film/render.mjs @@ -1,21 +1,29 @@ // render: a page's film to an MP4 through the page contract (engines/CONTRACT.md), for the engines that are web pages // and have no renderer of their own (the canvas engine; ClaudeAnimationBase also works, its render.mjs stays the -// documented path). Every frame of the span is drawn at t = k / fps in one of --workers browsers, each rendering a -// contiguous chunk, captured as JPEG (or PNG with --png) and piped into its own x264; the chunks are joined without -// re-encoding. Video only: put the sound on with bin/vh mux. +// documented path). Every frame of the span is drawn at t = k / fps and captured as JPEG (or PNG with --png). The span +// is cut into chunks of one GOP each; --workers browsers take the next chunk as they finish one (a slow chapter does not +// hold the others up), each chunk piped into an x264 of its own; the chunks are joined without re-encoding. Video only: +// put the sound on with bin/vh mux. // // usage: node tools/film/render.mjs --browser [--out out/film.mp4] [--fps 30] // [--range a:b | --frames k0:k1] [--dpr 1] [--workers 4] [--crf 16] [--preset medium] [--gop 60] [--png] [--quality 95] -// [--frame-timeout 120] [--force] [--real-clock] [--soft-gl] +// [--frame-timeout 120] [--estimate | --no-estimate] [--force] [--real-clock] [--soft-gl] // (bin/vh render finds the browser: CHROME_PATH, HyperFrames' chrome-headless-shell, then Chrome or Chromium) // -// --out default /out/.mp4; a lock file beside it stops a second render of the same file +// --out default /out/.mp4. A relative path is from the current folder, as every bin/vh path +// is (said when that puts the video outside the project); a lock file beside it stops a second render +// of the same file +// --fps 30 the frame rate drawn. A page that gives its own (window.film.fps; canvas-film's FILM.fps, which steps +// its grain once a frame) is told apart when the two differ // --range a:b only that span (frames a·fps up to b·fps); default the whole film // --frames k0:k1 only frames k0 up to k1 (k1 left out), frame k drawn at k / fps: exact, for bin/vh patch // --dpr 2 twice the pixels (4K for a 1920x1080 page) -// --gop 60 a keyframe every 60 frames exactly, from the span's first frame (default 2 s of frames); the chunks -// are whole GOPs, so a later patch can cut the video on keyframes +// --gop 60 a keyframe every 60 frames exactly, from the span's first frame (default 2 s of frames). Each chunk is +// one GOP, so a later patch can cut the video on keyframes, and the video does not depend on --workers // --png lossless frames into the encoder (slower, larger pipes); default JPEG at --quality 95 +// --estimate before a span of more than 600 frames to draw (--estimate: any span), a few frames spread over it are +// drawn and encoded first: the time a frame takes, the video's size and the disk it needs are printed, +// and a disk that cannot hold it stops the render (exit 2). A few seconds; --no-estimate skips it // --force render a HyperFrames composition this way too (by default: use npx hyperframes render, the measured path) // // The colour: Chrome's frames are full-range RGB; the encode converts them to limited-range BT.709 with all four colour @@ -23,9 +31,13 @@ // span, encoder settings, chunks, browser), for a later patch or re-render to match. Exit: 0 rendered and checked · // 1 a frame failed, the encoder stopped, or the result has the wrong frame count or colour tags; also when the page // reported an error while drawing (the video is written then, and the errors are listed) · 2 could not start. -// Ctrl-C (or SIGTERM) stops the encoders, deletes the chunks so far and the lock, and exits 130 (143). -// Before drawing it estimates the disk the render needs at its peak (the chunks and the joined copy): it stops (exit 2) -// when even a flat film would not fit, and says so when a heavy one might not. +// Stopped (Ctrl-C or SIGTERM: exit 130, 143) or failed, a render keeps the chunks it finished in ..parts beside +// the video: the same command again draws only the rest, while the page and every file it loaded, the options that shape +// the video (all but --workers, --frame-timeout and the estimate), the browser and these tools are unchanged; anything +// else changed, it says what and starts over. A chunk in which the page reported an error is never kept. +// Before drawing it checks the disk the render needs at its peak (the chunks and the joined copy): it stops (exit 2) +// when even a flat film would not fit; the estimate above measures it. +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { spawn, spawnSync } from "node:child_process"; @@ -33,12 +45,13 @@ import { openFilm } from "./host.mjs"; import { HOOK, check as checkGlyphs, collectDom, merge as mergeGlyphs, report as glyphReport } from "./glyphs.mjs"; process.stdout.on("error", (e) => { if (e.code !== "EPIPE") throw e; }); +const HERE = path.dirname(new URL(import.meta.url).pathname); const HELP = fs.readFileSync(new URL(import.meta.url), "utf8").split("\nimport ")[0].replace(/^\/\/ ?/gm, ""); const argv = process.argv.slice(2); if (!argv.length || argv.includes("-h") || argv.includes("--help")) { process.stdout.write(HELP.trimEnd() + "\n"); process.exit(argv.length ? 0 : 2); } const die = (msg) => { console.error("render: " + msg); process.exit(2); }; -const o = { fps: 30, dpr: 1, workers: 4, crf: 16, preset: "medium", quality: 95, frameTimeout: 120 }; -let target = null, out = null, range = null, frames = null, gop = null, png = false, force = false; +const o = { fps: 30, dpr: 1, workers: 4, crf: 16, preset: "medium", quality: 95, frameTimeout: 120, estimate: "auto" }; +let target = null, out = null, range = null, frames = null, gop = null, png = false, force = false, fpsGiven = false; for (let i = 0; i < argv.length; i++) { const a = argv[i], [k, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, undefined]; const val = () => { const v = inline !== undefined ? inline : argv[++i]; if (v === undefined) die(`${k} needs a value`); return v; }; @@ -46,7 +59,7 @@ for (let i = 0; i < argv.length; i++) { if (k === "--out") out = val(); else if (k === "--range") { range = val().split(":").map((v) => num(v, "--range", 0)); if (range.length !== 2 || range[1] <= range[0]) die("--range a:b, b > a"); } else if (k === "--frames") { frames = val().split(":").map((v) => num(v, "--frames", 0)); if (frames.length !== 2 || !frames.every(Number.isInteger) || frames[1] <= frames[0]) die("--frames k0:k1, whole frame numbers, k1 > k0"); } - else if (k === "--fps") o.fps = num(val(), k, 1); + else if (k === "--fps") { o.fps = num(val(), k, 1); fpsGiven = true; } else if (k === "--dpr") o.dpr = num(val(), k, 0.25); else if (k === "--workers") o.workers = Math.floor(num(val(), k, 1)); else if (k === "--crf") o.crf = num(val(), k, 0); @@ -54,6 +67,7 @@ for (let i = 0; i < argv.length; i++) { else if (k === "--gop") gop = Math.floor(num(val(), k, 1)); else if (k === "--quality") o.quality = Math.min(100, Math.floor(num(val(), k, 1))); else if (k === "--frame-timeout") o.frameTimeout = num(val(), k, 1); + else if (k === "--estimate" || k === "--no-estimate") { const e = k === "--estimate" ? "on" : "off"; if (o.estimate !== "auto" && o.estimate !== e) die("--estimate or --no-estimate, not both"); o.estimate = e; } else if (k === "--png") png = true; else if (k === "--force") force = true; else if (k === "--browser") o.browser = val(); @@ -78,6 +92,10 @@ if (first.kind === "hyperframes" && !force) { die("this is a HyperFrames composition: render it with npx hyperframes render (its renderer is the path this repo measured); --force renders it through the page contract anyway"); } if (!(first.duration > 0)) { await first.close(); die("the page gives no duration"); } +// the frame rate the page is made for: window.film.fps (engines/CONTRACT.md), else canvas-film's settings in a copy made +// before the engine gave it +let pageFps = null; +try { pageFps = +(await first.field("fps")) || +(await first.ev('typeof FILM === "object" && FILM && Number(FILM.fps) || 0', "reading FILM.fps")) || null; } catch { /* none */ } const fps = o.fps, last = Math.ceil(first.duration * fps - 1e-6); // the film's frame count: its last frame is last - 1 const k0 = frames ? frames[0] : range ? Math.round(range[0] * fps) : 0; const k1 = frames ? Math.min(frames[1], last) : !range || range[1] >= first.duration ? last : Math.round(range[1] * fps); @@ -87,6 +105,11 @@ gop = gop || Math.round(2 * fps); const stem = path.basename(first.page, path.extname(first.page)); const outFile = path.resolve(out || path.join(first.root, "out", `${stem}.mp4`)); try { fs.mkdirSync(path.dirname(outFile), { recursive: true }); } catch (e) { await first.close(); die(`cannot make ${rel(path.dirname(outFile))}: ${e.message}`); } +// a relative --out is from the current folder; one that lands outside the project (bin/vh run from the harness with +// --out out/x.mp4) is said in full, as the short form would read as the project's own out/ +const inside = (f, dir) => (f + path.sep).startsWith(dir + path.sep); +const outside = !!out && !path.isAbsolute(out) && !inside(fs.realpathSync(path.dirname(outFile)), first.root); +const shown = outside ? outFile : rel(outFile); // one render per output file: a second one would interleave its chunks with ours. A lock whose render is gone (its pid // no longer runs) is deleted and taken afresh; an empty one is a render that has not written its pid yet, unless it is old @@ -106,94 +129,207 @@ function takeLock() { } // the disk: the chunks and the joined copy are on it together before the chunks go, so about twice the video at the peak. // x264 at these CRFs has made 0.04 bits a pixel (a flat test page) to 0.4 (4K 3D with grain): refuse only when even 0.01 -// does not fit, say so when 0.06–0.4 might not (OVH_FREE_BYTES pretends that much is free: for tests) +// does not fit, say so when 0.06–0.4 might not and nothing measured it (OVH_FREE_BYTES pretends that much is free: for tests) const free = process.env.OVH_FREE_BYTES ? +process.env.OVH_FREE_BYTES : (() => { try { const s = fs.statfsSync(path.dirname(outFile)); return s.bavail * s.bsize; } catch { return Infinity; } })(); const pxs = Math.round(first.width * o.dpr) * Math.round(first.height * o.dpr) * n, gb = (b) => (b / 1e9).toFixed(b < 1e8 ? 3 : 1); if (2 * pxs * 0.01 / 8 > free) { await first.close(); die(`at least ${gb(2 * pxs * 0.01 / 8)} GB needed at the peak for ${n} frames at ${Math.round(first.width * o.dpr)}x${Math.round(first.height * o.dpr)} (likely ${gb(2 * pxs * 0.06 / 8)}–${gb(2 * pxs * 0.4 / 8)}), ${gb(free)} GB free on that disk`); } -if (2 * pxs * 0.4 / 8 > free) console.log(` disk: about ${gb(2 * pxs * 0.06 / 8)}–${gb(2 * pxs * 0.4 / 8)} GB needed at the peak (more for grain, 3D, photos), ${gb(free)} GB free: it may not fit`); const busy = takeLock(); if (busy) { await first.close(); die(busy); } const unlock = () => { try { if (+fs.readFileSync(lock, "utf8") === process.pid) fs.unlinkSync(lock); } catch { /* already gone */ } }; process.on("exit", unlock); +// chunks are whole GOPs, one each, so every keyframe of the joined video falls on the grid k0 + m·gop (a patch can cut +// there) and a chunk's bytes depend on its own frames only, not on how many workers there were or which drew it +const plan = Array.from({ length: Math.ceil(n / gop) }, (_, i) => [k0 + i * gop, Math.min(k1, k0 + (i + 1) * gop)]); const parts = path.join(path.dirname(outFile), `.${path.basename(outFile)}.parts`); -fs.rmSync(parts, { recursive: true, force: true }); fs.mkdirSync(parts, { recursive: true }); +const kept = (i) => path.join(parts, `part${String(i).padStart(4, "0")}.mp4`); // a chunk finished clean: kept for a resume +const isKept = (f) => /^part\d{4,}\.mp4$/.test(f); const encoders = new Set(); +// after a stop or a failure the finished chunks stay (and plan.json, which says what they were made from); the rest goes +const keepFinished = () => { + let names = []; + try { names = fs.readdirSync(parts); } catch { return 0; } + const k = names.filter(isKept).length; + if (!k) { fs.rmSync(parts, { recursive: true, force: true }); return 0; } + for (const f of names) if (f !== "plan.json" && !isKept(f)) fs.rmSync(path.join(parts, f), { recursive: true, force: true }); + return k; +}; +const keptSay = (k) => `${k} of ${plan.length} chunks are kept in ${rel(parts)}: the same command again draws only the rest (a changed page starts over)`; for (const [sig, code] of [["SIGINT", 130], ["SIGTERM", 143]]) process.once(sig, () => { // the lock goes on exit; the browsers and their profiles too (tools/textcheck/browser.mjs) - console.error(`render: stopped (${sig}); the chunks so far are deleted`); for (const ff of encoders) ff.kill("SIGKILL"); - fs.rmSync(parts, { recursive: true, force: true }); + const k = keepFinished(); + console.error(`render: stopped (${sig}); ` + (k ? keptSay(k) : "the chunks so far are deleted")); process.exit(code); }); -// chunks are whole GOPs, so every keyframe of the joined video falls on the grid k0 + m·gop (a patch can cut there) -const chunk = Math.ceil(Math.ceil(n / Math.max(1, Math.min(o.workers, n))) / gop) * gop, K = Math.ceil(n / chunk); -const chunks = Array.from({ length: K }, (_, w) => [k0 + w * chunk, Math.min(k1, k0 + (w + 1) * chunk)]).filter(([a, b]) => b > a); const COLOR = "colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv"; // x264's yuv420p needs an even width and height: an odd one (1366 x 768 at --dpr 1.5 is 2049 x 1152) loses its last column or row const EVEN = "crop=trunc(iw/2)*2:trunc(ih/2)*2", px = [Math.round(first.width * o.dpr), Math.round(first.height * o.dpr)]; const vf = png ? `${EVEN},scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int,format=yuv420p,setparams=${COLOR}` : `${EVEN},scale=in_color_matrix=bt601:in_range=pc:out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int,format=yuv420p,setparams=${COLOR}`; +const x264 = (file) => spawn("ffmpeg", ["-v", "error", "-y", "-f", "image2pipe", "-framerate", String(fps), "-c:v", png ? "png" : "mjpeg", "-i", "-", + "-vf", vf, "-c:v", "libx264", "-preset", o.preset, "-crf", String(o.crf), "-g", String(gop), "-keyint_min", String(gop), "-sc_threshold", "0", + "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-r", String(fps), file], { stdio: ["pipe", "ignore", "pipe"] }); +const capture = png ? { format: "png" } : { format: "jpeg", quality: o.quality }; + +// ---- what the chunks are made from: a resume takes the finished ones only while all of it is the same ---- +const sha = (f) => { + const h = crypto.createHash("sha256"), fd = fs.openSync(f, "r"), buf = Buffer.alloc(1 << 22); + try { for (let r; (r = fs.readSync(fd, buf, 0, buf.length, null)) > 0;) h.update(buf.subarray(0, r)); } finally { fs.closeSync(fd); } + return h.digest("hex"); +}; +const recipe = { + page: fs.realpathSync(first.page), kind: first.kind, width: first.width, height: first.height, dpr: o.dpr, fps, frames: [k0, k1], gop, + capture: png ? "png" : `jpeg q${o.quality}`, crf: o.crf, preset: o.preset, vf, real_clock: !!o.realClock, soft_gl: !!o.softGl, browser: first.version, + ffmpeg: (spawnSync("ffmpeg", ["-version"], { encoding: "utf8" }).stdout || "").split("\n")[0], + tools: crypto.createHash("sha256").update(["render.mjs", "host.mjs", "clock.js", "glyphs.mjs"].map((f) => sha(path.join(HERE, f))).join()).digest("hex"), +}; +const loaded = new Map(); // every file a browser of this render was sent, and its sha256 +const planFile = path.join(parts, "plan.json"); +const writePlan = (film) => { // before a chunk is kept: the files its frames loaded are on record first + for (const f of film.served()) if (!loaded.has(f)) { try { loaded.set(f, sha(f)); } catch { loaded.set(f, null); } } + fs.writeFileSync(planFile, JSON.stringify({ note: "what bin/vh render's chunks here were made from; a render resumes from them only while all of it is the same", recipe, files: Object.fromEntries(loaded) }, null, 1) + "\n"); +}; +let reuse = new Set(), why = null; +{ + let old = null, names = []; + try { names = fs.readdirSync(parts); old = JSON.parse(fs.readFileSync(planFile, "utf8")); } catch { /* none, or not ours */ } + const done = names.filter(isKept); + if (old && done.length) { + const changed = Object.keys(recipe).filter((k) => JSON.stringify((old.recipe || {})[k]) !== JSON.stringify(recipe[k])); + const files = Object.entries(old.files || {}).filter(([f, h]) => { try { return sha(f) !== h; } catch { return true; } }).map(([f]) => rel(f)); + if (!changed.length && !files.length) { + for (const [f, h] of Object.entries(old.files)) loaded.set(f, h); + reuse = new Set(plan.map((_, i) => i).filter((i) => done.includes(path.basename(kept(i))) && fs.statSync(kept(i)).size > 0)); + } else why = [...changed.map((k) => k === "frames" ? "the span" : k === "tools" ? "these tools" : k), ...files.slice(0, 3), ...(files.length > 3 ? [`${files.length - 3} more files`] : [])].join(", "); + } else if (done.length) why = "no record of what they were made from"; + if (why) console.log(` the ${done.length} chunks kept from an earlier render are not used (changed: ${why}); starting over`); + if (reuse.size) { const keep = new Set([...reuse].map((i) => path.basename(kept(i)))); for (const f of names) if (f !== "plan.json" && !keep.has(f)) fs.rmSync(path.join(parts, f), { recursive: true, force: true }); } + else { fs.rmSync(parts, { recursive: true, force: true }); fs.mkdirSync(parts, { recursive: true }); } + writePlan(first); +} +const todo = plan.map((_, i) => i).filter((i) => !reuse.has(i)), nTodo = todo.reduce((s, i) => s + plan[i][1] - plan[i][0], 0); +const W = Math.min(o.workers, todo.length); console.log(`render: ${rel(first.page)} · ${first.kind === "film" ? "window.film" : first.kind} · ${first.width}x${first.height}${o.dpr !== 1 ? ` @${o.dpr}x` : ""} · ` + - `${n} frames at ${fps} fps (${(k0 / fps).toFixed(3)}–${(k1 / fps).toFixed(3)} s) · ${chunks.length} worker${chunks.length > 1 ? "s" : ""} · ${png ? "PNG" : `JPEG q${o.quality}`} → x264 crf ${o.crf} ${o.preset}, keyframe every ${gop} · ${first.version}`); + `${n} frames at ${fps} fps (${(k0 / fps).toFixed(3)}–${(k1 / fps).toFixed(3)} s) · ${plan.length} chunk${plan.length > 1 ? "s" : ""}, ${W} worker${W === 1 ? "" : "s"} · ${png ? "PNG" : `JPEG q${o.quality}`} → x264 crf ${o.crf} ${o.preset}, keyframe every ${gop} · ${first.version}`); for (const p of first.loadProblems) console.log(` while loading: ${p}`); +if (outside) console.log(` --out ${out} is from the current folder: ${outFile}, outside the project (${first.root})`); if (px[0] % 2 || px[1] % 2) console.log(` note: ${px[0]}x${px[1]} pixels; x264 needs even sizes, so the video is ${px[0] - px[0] % 2}x${px[1] - px[1] % 2}`); +if (pageFps && Math.abs(pageFps - fps) > 1e-6) console.log(` fps: drawn at ${fps} fps (${fpsGiven ? "--fps" : "the default; --fps sets it"}), the page says ${pageFps} (window.film.fps or FILM.fps): what it steps once a frame (grain) steps at its rate; make the two agree`); +if (reuse.size) console.log(` resuming: ${reuse.size} of ${plan.length} chunks kept from the last render of this file (${plan.length - reuse.size} to draw)`); let failed = null, done = 0; -const glyphs = []; -const T0 = Date.now(), problems = []; +// the first failure ends the render; the other workers finish the chunk they are on, so that it is kept for a resume +const failWith = (msg) => { if (failed) return; failed = msg; if (W > 1) console.log(` stopping: ${msg} (the other workers finish their chunk first)`); }; const within = (p, s, what) => { let timer; return Promise.race([p, new Promise((_, no) => { timer = setTimeout(() => no(new Error(`${what}: no picture after ${s} s (--frame-timeout)`)), s * 1000); })]).finally(() => clearTimeout(timer)); }; -const progress = setInterval(() => { if (done) console.log(` ${done} / ${n} frames · ${((Date.now() - T0) / 1000).toFixed(0)} s · about ${(((Date.now() - T0) / done) * (n - done) / 1000).toFixed(0)} s to go`); }, 15000); +const size = (b) => b >= 1e9 ? `${(b / 1e9).toFixed(1)} GB` : b >= 1e6 ? `${(b / 1e6).toFixed(0)} MB` : `${Math.ceil(b / 1e3)} kB`; +const ftime = (s) => s < 90 ? `${s.toFixed(0)} s` : s < 5400 ? `${(s / 60).toFixed(0)} min` : `${(s / 3600).toFixed(1)} h`; + +// ---- the estimate: a few frames spread over the span, drawn and encoded as the render will ---- +if (todo.length && (o.estimate === "on" || (o.estimate === "auto" && nTodo > 600))) { + const S = Math.min(6, todo.length), at = [...new Set(Array.from({ length: S }, (_, i) => plan[todo[Math.floor((i + 0.5) * todo.length / S)]][0]))]; + const ms = [], runs = []; + try { + await within(first.frame(at[0] / fps, capture), o.frameTimeout, `t=${(at[0] / fps).toFixed(3)} s`); // not timed: the first frame also compiles and decodes + for (const k of at) { // a keyframe and the two frames after it: what a GOP's first frame and its others cost + const imgs = []; + for (const kk of [k, k + 1, k + 2].filter((x) => x < k1)) { + const t0 = performance.now(); + imgs.push((await within(first.frame(kk / fps, capture), o.frameTimeout, `t=${(kk / fps).toFixed(3)} s`)).png); + ms.push(performance.now() - t0); + } + runs.push(imgs); + } + } catch (e) { await first.close(); console.error(`render: ${e.message} (drawing the estimate's frames)`); keepFinished(); process.exit(1); } + first.problems(); // what these frames report, they report again when the render draws them + const sizes = await Promise.all(runs.map((imgs, j) => new Promise((ok) => { + const f = path.join(parts, `estimate${j}.mp4`), ff = x264(f); + encoders.add(ff); ff.stdin.on("error", () => {}); ff.stderr.resume(); + for (const img of imgs) ff.stdin.write(img); + ff.stdin.end(); + ff.on("close", (code) => { + encoders.delete(ff); + const p = code === 0 ? spawnSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "packet=size", "-of", "csv=p=0", f], { encoding: "utf8" }) : null; + fs.rmSync(f, { force: true }); + const b = p && p.status === 0 ? p.stdout.split(/\s+/).filter(Boolean).map(Number) : []; + ok(b.length ? (b[0] + (gop - 1) * (b.length > 1 ? b.slice(1).reduce((x, y) => x + y, 0) / (b.length - 1) : b[0] / 4)) / gop : null); // bytes a frame: the keyframe once a GOP, the others like those after it + }); + }))); + const mean = ms.reduce((s, x) => s + x, 0) / ms.length, got = sizes.filter((x) => x !== null); + const film = got.length ? n * got.reduce((s, x) => s + x, 0) / got.length : null; + // a few places can miss the busiest stretches (measured: a film up to twice what its samples said), so it is a range + const keptBytes = [...reuse].reduce((s, i) => s + fs.statSync(kept(i)).size, 0), more = film === null ? null : Math.max(0, 2 * film - keptBytes); + console.log(` estimate (${ms.length} frames drawn in one browser, ${Math.min(...ms).toFixed(0)}–${Math.max(...ms).toFixed(0)} ms each): ${nTodo} frames in about ` + + `${ftime(nTodo * mean / 1000 / W)} with ${W} worker${W === 1 ? "" : "s"} (more when they share a GPU or the machine is busy)` + + (film === null ? "; the size could not be measured" : `; the video ${size(film)}–${size(2 * film)} (x264 crf ${o.crf} ${o.preset} on those frames), ` + + `${size(more)}–${size(more + 2 * film)} more on that disk at the peak (the chunks, then the joined copy), ${size(free)} free${more + 2 * film > free ? ": it may not fit" : ""}`)); + if (more !== null && more > free) { await first.close(); keepFinished(); die(`about ${size(more)} is needed at the peak and ${size(free)} is free on that disk: free some, or a higher --crf; --no-estimate renders without measuring`); } +} else if (2 * pxs * 0.4 / 8 > free) console.log(` disk: about ${gb(2 * pxs * 0.06 / 8)}–${gb(2 * pxs * 0.4 / 8)} GB needed at the peak (more for grain, 3D, photos), ${gb(free)} GB free: it may not fit`); + +const glyphs = [], fileOf = plan.map((_, i) => (reuse.has(i) ? kept(i) : null)); +const T0 = Date.now(), problems = []; +const progress = setInterval(() => { if (done) console.log(` ${done} / ${nTodo} frames · ${((Date.now() - T0) / 1000).toFixed(0)} s · about ${(((Date.now() - T0) / done) * (nTodo - done) / 1000).toFixed(0)} s to go`); }, 15000); progress.unref(); -async function worker(w, [a, b]) { - let film = w === 0 ? first : null; - const file = path.join(parts, `part${String(w).padStart(3, "0")}.mp4`); - const ff = spawn("ffmpeg", ["-v", "error", "-y", "-f", "image2pipe", "-framerate", String(fps), "-c:v", png ? "png" : "mjpeg", "-i", "-", - "-vf", vf, "-c:v", "libx264", "-preset", o.preset, "-crf", String(o.crf), "-g", String(gop), "-keyint_min", String(gop), "-sc_threshold", "0", - "-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-r", String(fps), file], { stdio: ["pipe", "ignore", "pipe"] }); +// one chunk into its own encoder; → { encoded: a promise that settles once the encoder is done with it } (the browser goes +// on to the next chunk meanwhile). A chunk is kept for a resume only when every frame went in and the page reported nothing +async function chunk(film, i) { + const [a, b] = plan[i], file = path.join(parts, `part${String(i).padStart(4, "0")}.new.mp4`), ff = x264(file); encoders.add(ff); let ffErr = ""; ff.stderr.on("data", (d) => { ffErr = (ffErr + d).slice(-2000); }); const closed = new Promise((ok) => ff.on("close", (code, sig) => ok(code ?? sig))); // an encoder that stops early (a full disk, a frame it refuses) ends the render: nothing waits on a pipe no one reads - let gone = false; + let gone = false, wrote = 0, said = false; const exited = new Promise((ok) => ff.once("exit", () => { gone = true; encoders.delete(ff); ok(); })); ff.stdin.on("error", () => { /* the encoder is gone: exited says so */ }); - let wrote = 0; try { - if (!film) film = await openFilm(target, { ...o, runtime: "auto", prelude: [HOOK] }); - const capture = png ? { format: "png" } : { format: "jpeg", quality: o.quality }; - for (let k = a; k < b && !failed && !gone; k++) { + for (let k = a; k < b && !gone; k++) { const t = k / fps; const { png: img } = await within(film.frame(t, capture), o.frameTimeout, `t=${t.toFixed(3)} s`); const later = film.problems(); - if (later.length) problems.push(`t=${t.toFixed(3)} s: ${later.join(" | ")}`); + if (later.length) { problems.push(`t=${t.toFixed(3)} s: ${later.join(" | ")}`); said = true; } if (!ff.stdin.write(img)) await Promise.race([new Promise((ok) => ff.stdin.once("drain", ok)), exited]); if (gone) break; wrote++; done++; } - } catch (e) { failed = failed || e.message; } + } catch (e) { failWith(e.message); } ff.stdin.end(); + return { encoded: closed.then((code) => { + if (code !== 0 || wrote < b - a) return failWith(`the encoder for frames ${a}–${b - 1} ${wrote < b - a ? `stopped after ${wrote} of them` : "failed"} (exit ${code}): ${ffErr.trim() || "no message"}`); + if (said) { fileOf[i] = file; return; } // in this video, not for a resume: drawn again next time, and said again + writePlan(film); fs.renameSync(file, kept(i)); fileOf[i] = kept(i); + }) }; +} +let next = 0; +async function worker(w) { + let film = w === 0 ? first : null; + const encoding = []; + try { + if (!film) film = await openFilm(target, { ...o, runtime: "auto", prelude: [HOOK] }); + while (!failed && next < todo.length) encoding.push((await chunk(film, todo[next++])).encoded); + } catch (e) { failWith(e.message); } + await Promise.all(encoding); // the characters this worker drew, each in the font it asked for? (tools/film/glyphs.mjs; a finding is reported, not fatal) // (canvas text is noted as it is drawn; the DOM's text as it stands after this worker's last frame) // (never in the way of the render: a minute at most, then it is said to be unchecked) - if (film && !failed && !gone) { + if (film && !failed) { try { glyphs.push(await within((async () => { await collectDom(film); return checkGlyphs(film); })(), 60, "the glyph check")); } catch (e) { glyphs.push({ error: /no picture after/.test(e.message) ? "it did not finish within 60 s" : e.message }); } } - const code = await closed; if (film) await film.close(); - if (!failed && (code !== 0 || wrote < b - a)) failed = `the encoder for frames ${a}–${b - 1} ${wrote < b - a ? `stopped after ${wrote} of them` : "failed"} (exit ${code}): ${ffErr.trim() || "no message"}`; - return file; } -const files = await Promise.all(chunks.map((c, w) => worker(w, c))); +await Promise.all(Array.from({ length: W }, (_, w) => worker(w))); +if (!W) await first.close(); clearInterval(progress); -if (failed) { fs.rmSync(parts, { recursive: true, force: true }); console.error(`render: ${failed}`); process.exit(1); } -fs.writeFileSync(path.join(parts, "list.txt"), files.map((f) => `file '${path.basename(f)}'`).join("\n") + "\n"); +const fail = (msg) => { const k = keepFinished(); console.error(`render: ${msg}`); if (k) console.error(` ${keptSay(k)}`); process.exit(1); }; +if (failed) fail(failed); +// each chunk's length in frames: an MP4's own duration is rounded to its movie timescale (1/1000 s), which would move the +// chunk after it by a few ticks when a GOP is not a whole number of milliseconds (--gop 20 at 30 fps) +fs.writeFileSync(path.join(parts, "list.txt"), plan.map(([a, b], i) => `file '${path.basename(fileOf[i])}'\nduration ${((b - a) / fps).toFixed(9)}`).join("\n") + "\n"); // joined inside the parts folder and moved onto whole: a join cut short (Ctrl-C) never replaces an earlier video const joined = path.join(parts, `joined${path.extname(outFile)}`); const join = spawnSync("ffmpeg", ["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", path.join(parts, "list.txt"), "-c", "copy", "-movflags", "+faststart", joined], { encoding: "utf8" }); -if (join.status !== 0) { fs.rmSync(parts, { recursive: true, force: true }); console.error(`render: joining the chunks failed: ${(join.stderr || join.signal || "").toString().trim()}`); process.exit(1); } +if (join.status !== 0) fail(`joining the chunks failed: ${(join.stderr || join.signal || "").toString().trim()}`); fs.renameSync(joined, outFile); // the frame count the MP4 records (counting by decoding would take minutes on a long 4K film) const probe = spawnSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", @@ -206,17 +342,17 @@ const sidecar = { // from the video's folder when the video is in the project (it moves with it), else where the page is page: (() => { // first.root is a real path (host.mjs), so the video's folder is compared as one too (macOS /tmp is /private/tmp) const dir = fs.realpathSync(path.dirname(outFile)), page = path.join(first.root, path.basename(first.page)); - return (dir + path.sep).startsWith(first.root + path.sep) ? path.relative(dir, page) : first.page; + return inside(dir, first.root) ? path.relative(dir, page) : first.page; })(), kind: first.kind, width: first.width, height: first.height, dpr: o.dpr, pixels: [st.width, st.height], fps, frames: n, span: [+(k0 / fps).toFixed(6), +(k1 / fps).toFixed(6)], first_frame: k0, capture: png ? "png" : `jpeg q${o.quality}`, encoder: { codec: "libx264", crf: o.crf, preset: o.preset, gop, keyint_min: gop, sc_threshold: 0, pix_fmt: st.pix_fmt, color: [st.color_range, st.color_space, st.color_primaries, st.color_transfer] }, - chunks: chunks.map(([a, b]) => [a, b]), browser: first.version, real_clock: !!o.realClock, soft_gl: !!o.softGl, seconds: +secs.toFixed(1), + chunks: plan.map(([a, b]) => [a, b]), reused_chunks: reuse.size, browser: first.version, real_clock: !!o.realClock, soft_gl: !!o.softGl, seconds: +secs.toFixed(1), }; fs.writeFileSync(outFile + ".render.json", JSON.stringify(sidecar, null, 1) + "\n"); const okCount = +st.nb_frames === n, okColor = st.pix_fmt === "yuv420p" && st.color_range === "tv" && st.color_space === "bt709"; -console.log(` ${rel(outFile)}: ${st.nb_frames} frames, ${st.width}x${st.height}, ${st.pix_fmt} ${st.color_range}/${st.color_space}, in ${secs.toFixed(0)} s` + - ` (${(secs * 1000 / n).toFixed(0)} ms a frame); how it was made: ${rel(outFile + ".render.json")}`); +console.log(` ${shown}: ${st.nb_frames} frames, ${st.width}x${st.height}, ${st.pix_fmt} ${st.color_range}/${st.color_space}, in ${secs.toFixed(0)} s` + + (nTodo ? ` (${(secs * 1000 / nTodo).toFixed(0)} ms a frame${reuse.size ? `, ${nTodo} of them drawn` : ""})` : " (every chunk kept)") + `; how it was made: ${outside ? outFile + ".render.json" : rel(outFile + ".render.json")}`); for (const p of problems) console.log(` ${p}`); { // a character in another font than it asks for, or in none: said, not fatal const okG = glyphs.filter((g) => !g.error), errG = glyphs.filter((g) => g.error);