Skip to content

bin/vh render and bin/vh determinism: an MP4 and a hard-rule-1 check for any page that meets the contract - #82

Merged
ZLHad merged 8 commits into
mainfrom
claude/contract-render
Oct 11, 2026
Merged

ZLHad merged 8 commits into
mainfrom
claude/contract-render

Conversation

@ZLHad

@ZLHad ZLHad commented Oct 11, 2026

Copy link
Copy Markdown
Owner

What and why · 改了什么、为什么

v0.4 计划 M1 的第三块(计划里的 A3)。自建 canvas 引擎原来靠每支片子复制一份的脚本出片(film.mjs master),确定性检查也只有风格样片有。现在两样都建在页面合约(#81 的 engines/CONTRACT.md)上,所有网页引擎都能用。这个 PR 叠在 #81 上,#81 合并后 base 会自动改回 main。

  • bin/vh render <项目|页面>(tools/film/render.mjs):

    • 每一帧取 t = k / fps,分给 --workers 个浏览器,每个浏览器渲连续的一段;截成 JPEG(--png 无损)直接送进各自的 x264,最后无损拼接;
    • 出片转成 tv 范围的 BT.709,四个色彩标签齐全;
    • 每 --gop 帧一个关键帧,从第一帧算起;分段都按整数个 GOP 切,所以拼起来以后关键帧也落在同一套格子上,以后局部重渲(patch)能在关键帧处切开;
    • 旁边写一份 <out>.render.json,记下这支片子是怎么出的;
    • 同一个输出文件有锁,防止两次渲染同时写;
    • 渲完核对帧数和色彩标签;
    • 只出画面,声音用 bin/vh mux 套上;
    • HyperFrames 合成会被指回 npx hyperframes render,那是本仓库量过的路径;加 --force 才在这里渲。
  • bin/vh determinism <项目|页面>(tools/film/determinism.mjs):同几个时刻画四遍,比较无损 PNG:

    • A:一个浏览器按顺序画;
    • B:同一个浏览器先画别处的一帧,再倒着画;
    • C:另一个浏览器进程;
    • D:关掉宿主的时钟兜底。

    B 或 C 低于 --min-psnr(默认 45 dB)时退出码 1。A、B、C 一致、只有 D 不同时只给警告:说明页面读了 Math.random 或真实时间,靠宿主把时钟钉住才一致,换一个不钉时钟的渲染器就会变。

  • 宿主能截 JPEG。CI 渲测试页并核对帧数、色彩标签、关键帧位置(--gop 30 分两段,关键帧在 0、1、2 s)和 sidecar;determinism 在测试页和一个带状态的页面上各跑一次。CLAUDE.md 的命令列表加了两条(AGENTS.md 已同步),engines/CONTRACT.md 和 playbook/02 指向它们。

English summary. bin/vh render renders any page that meets the contract to an MP4 (parallel chunks of whole GOPs into x264, BT.709 tv range, fixed keyframes, a sidecar, a lock, final checks); bin/vh determinism draws the same moments four ways and compares lossless PNGs, failing on order or process differences and warning when only the clockless pass differs.

How it was verified · 怎么验证的

  • showcase 01(手绘,系统 Chrome):
    • 288 帧(24 fps,3 个 worker)35 s 渲完;
    • 4.25 s 那一帧按 BT.709 解码,和无损静帧比是 38.6 dB。引擎自己的 render.mjs --clip 出的同一帧是 36.8 dB,这类带颗粒的画面本来就在这个水平。
  • determinism:showcase 01 和 showcase 02 每个时刻四种画法都逐字节相同;带状态的测试页 16.5 dB,退出码 1;读 Math.random 的测试页 A–C 相同,D 给出警告。
  • 测试页:
    • 90 帧,tv/BT.709;
    • --gop 60 两个 worker 时关键帧在 0 s 和 2 s;--gop 30 时在 0、1、2 s;
    • 第二次渲同一个文件不会被锁挡住。
  • tools/ci.sh --committed 通过(shellcheck、pyflakes 用 uvx 跑)。

Checklist · 自查

  • tools/ci.sh --committed passes · 通过
  • User-visible change noted in CHANGELOG.md → Unreleased · 已记进 CHANGELOG
  • Changed CLAUDE.md → ran bin/vh sync-agents · 命令列表加了 render、determinism,已同步
  • No API keys, LOCAL.md, projects/ or render output committed · 没有提交 key、LOCAL.md、projects/ 和渲染产物
  • Swatch media under styles/<slug>/media/ changed only on purpose · 不适用
  • README changes made in both README.md and README.zh-CN.md · 不适用

ZLHad added 3 commits October 11, 2026 16:26
…b-page engine's source

- engines/CONTRACT.md: what a page gives (window.film = { frame(t),
  duration, ready, width?, height? }, OpenFilm's protocol 0.1 fields,
  plus optional texts, events, chapters, cues), what the host does, and
  how each engine meets it.
- tools/film/: the host (served on 127.0.0.1, window.filmHost and the
  clock set before the page runs, ready awaited, lossless PNG captured),
  the clock prelude, and the HyperFrames adapter that loads a
  composition's own runtime and turns window.__player.renderSeek(t) into
  frame(t).
- bin/vh frames --at / --range / --strip; CI tests on fixtures.
- ClaudeAnimationBase writes window.film; textcheck reads window.film as
  it reads the older window.__vh.
- Measured: showcase 00 matches HyperFrames' snapshot pixel for pixel;
  showcase 01 matches render.mjs --stills in the same browser.
- Errors count: a failed request or page error while loading, and one a
  frame leaves behind (checked after the last frame), now fail the run as
  the contract says; strip frames are numbered by what was written.
- ready is called as a method (film.ready()), as OpenFilm's hosts do; the
  same in textcheck, and a texts function keeps its this.
- The host answers /__vh/ only for its own scripts (a page asking for
  /__vh/constructor crashed it), pins the time zone to UTC, and the
  HyperFrames adapter fails in 2 s on a composition with no length.
- frames: --at rounds to the millisecond and drops repeats, --range
  rejects a step of 0 and leaves out the film's own end, empty values and
  --size 0x0 are refused, every exit closes the browser.
- ClaudeAnimationBase: duration follows a ?loop= page.
- Docs: what wins for the size, the page's own folder as the root, the
  tolerance for repeated frames (showcase 00's 14 s differs by order, not
  from HyperFrames), Math.random as the only clock reported, the video
  seek and Range requests not done yet.
…for any page that meets the contract

- render: every frame at k/fps in parallel browsers, each a contiguous
  chunk of whole GOPs piped into its own x264, joined without
  re-encoding; BT.709 tv range with all colour tags, a keyframe every
  --gop frames, <out>.render.json, a lock per output, a frame-count and
  colour check. HyperFrames compositions are pointed to npx hyperframes
  render unless --force.
- determinism: the same moments drawn in order, reversed after another
  frame, in a second browser and without the host's clock; PSNR of the
  lossless PNGs, exit 1 under --min-psnr, a warning when only the
  clockless pass differs.
- The host can capture JPEG; CI tests on the fixtures; CLAUDE.md lists
  both commands.
ZLHad added 4 commits October 11, 2026 17:24
…nder draws

A strip that starts on a frame drew its frames at t rounded to the
millisecond. An engine that floors t (ClaudeAnimationBase's onTwos,
shakeXY) then drew another picture at some of them: at 24 fps, frames 38
and 80 of showcase 01 came out at 1.583 s and 3.333 s instead of 38/24 and
80/24, 26 dB from the render's frame. Now the strip is on render's frame
grid (37 dB from the render's frame, what the encode leaves); messages
still show t to the millisecond.
- render: an encoder that stops early (killed, a full disk, a frame it
  refuses) ends the render at once instead of a wait on a pipe no one
  reads; the message says which frames and after how many.
- render: Ctrl-C or SIGTERM kills the encoders and deletes the chunks so
  far and the lock (exit 130 / 143); a failed join deletes the chunks too.
- render: an output folder it cannot make is exit 2 with the reason, not a
  stack trace; the lock is taken over only from a render that is gone (a
  dead pid, or an empty lock older than 10 s), by deleting it and creating
  it afresh.
- render: an odd pixel size (1366x768 at --dpr 1.5) is cropped to even
  sizes instead of failing after the whole film was drawn; a note says so.
- render: a --range that reaches the end ends on the same last frame as no
  range; --out must be an .mp4 or .mov file, not a folder; --crf= is
  refused instead of becoming crf 0.
- render: the sidecar names the page relative to the video when the video
  is in the project, and records soft_gl; the exit codes in the header
  list every case.
- determinism: pass D's errors are warnings, as its differences are.
- bin/vh frames, render and determinism -h print the usage line and every
  option; the tools' help ends with a newline.
…ge path holds through a symlink

- The chunks are joined inside the parts folder and the result renamed
  onto <out>: a Ctrl-C during the join used to leave a shorter, valid MP4
  in place of an earlier video.
- The sidecar compares the video's real folder with the page's real
  folder (macOS /tmp is /private/tmp), so a project reached through a
  symlink still records "../index.html".
- A bad number is quoted in the message ("--crf: "" is not a number").
ZLHad added a commit that referenced this pull request Oct 11, 2026
With #82's bin/vh render --png --dpr 2 on showcase 01 (2 workers, 120 frames
at 3840x2160): 883 ms a frame instead of 957 ms, node's peak RSS about 440 MB
instead of 1040 MB, and the same MP4 byte for byte.
ZLHad added a commit that referenced this pull request Oct 11, 2026
The receive loop's comment fits in two lines. The CHANGELOG says which
figures are medians of 7, of 8 or 4 shots and of two render runs, and that
#81, #82 and #84 are still open, so it does not read as if bin/vh frames,
render and determinism were already on main.
Base automatically changed from claude/page-contract to main October 11, 2026 14:20
# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	CLAUDE.md
#	bin/vh
#	engines/CONTRACT.md
#	tools/ci.sh
#	tools/film/frames.mjs
#	tools/film/host.mjs
@ZLHad
ZLHad marked this pull request as ready for review October 11, 2026 14:21
@ZLHad
ZLHad enabled auto-merge (squash) October 11, 2026 14:21
@ZLHad
ZLHad merged commit 6e26934 into main Oct 11, 2026
2 checks passed
@ZLHad
ZLHad deleted the claude/contract-render branch October 11, 2026 14:25
ZLHad added a commit that referenced this pull request Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant