Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ OpenVideoHarness/
├── CLAUDE.md / AGENTS.md 入口(本文件;AGENTS.md 由 bin/vh sync-agents 生成,内容相同)
├── README.md / README.zh-CN.md 给人看的说明;CONTRIBUTING.md 是改本仓库时的规则;install.sh 一键安装
├── LOCAL.md 本机环境(不入库;模板是 LOCAL.example.md)
├── bin/vh 命令行:doctor · setup · types · effort · new · style · recipes · hf-init · install-skill · sync-agents · tts · voices · captions · beats · music · sfx · mix · mux · qa · readcheck · storyboard · rhythm · cover-preview · sheet · check · gif · review · desk
├── bin/vh 命令行:doctor · setup · types · effort · new · style · recipes · hf-init · install-skill · sync-agents · tts · voices · captions · beats · music · sfx · mix · mux · qa · readcheck · textcheck · storyboard · rhythm · cover-preview · sheet · check · gif · review · desk
├── tools/ bin/vh 背后的脚本;audio/README.md 是声音命令的参数手册,desk/README.md 是审阅台的读取约定;ci.sh 是仓库自检
├── skills/open-video-harness/ 轻量 skill:在任何目录把做视频的请求引到本仓库
├── video-types/ 9 类视频(09 实验中):工作流、审美、禁止项、Prompt 增量块、自查重点、案例
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

**`bin/vh textcheck`: canvas text covered by the picture, too faint, or out of the frame**
- Why: `npx hyperframes check` measures DOM text only; text a script draws into a `<canvas>` is pixels to it. A 457 s canvas film's own script measured its 106 reads this way and found 3 with too little contrast that frame review had missed. This makes that check a command any canvas film can run.
- A composition opts in with one line, `window.__vh = { draw: (t) => …, texts }` (`texts` optional). Before its scripts run, the check hooks the canvas's `fillText` and `strokeText`, so the film's own text functions stay as they are: kinetic type drawn one character at a time, a line drawn in parts, and a "·" or an arrow drawn as a shape still match the read.
- For each registered read (`--texts`, else `window.__vh.texts`, else the `out/texts.json` readcheck reads), at 3 times across its window: the frame as drawn, the frame without that read and the read alone as a mask give the share of its glyphs that show (under 70%: covered), and its contrast with the picture under it and with what is right around it, so an outline, a shadow or a plate counts (both under 3:1: too faint). Outside or cut by the frame and under a DOM caption also fail; so does a frame function that throws. Exit 1 on any of those, 2 when it could not check (no `window.__vh.draw`, no browser, or no canvas text measured at all). The picture is every canvas, video and image of the page over its background, so text on a transparent layer is measured against the layer behind it.
- Warnings only: canvas text outside the registry (scanned every second, each line judged once where it is most opaque), reads missing from some samples or never drawn as canvas text, DOM captions (`data-read="subtitle"`) over a bright picture (the share of the pixels under their lines within 3:1 of the caption's colour), and a frame that is not the same when drawn twice. Crops and every number go to `out/check/textcheck/`.
- No npm dependency: Node drives a headless Chrome over the DevTools protocol through a pipe, and serves the project folder on 127.0.0.1. The browser: `CHROME_PATH`, HyperFrames' chrome-headless-shell, then Chrome or Chromium. Pixels are copied into the check's own canvas before reading, because after a few `getImageData` calls Chrome moves a canvas off the GPU and the film then draws every later frame on the CPU, many times slower.
- On a copy of the 457 s film with the line added (107 reads, about 13 s on an M3 Max): one read drawn above the frame at its sample time, one read that is an image, ten reads missing from their first sample, two dim notes outside the registry under 3:1, one line outside the frame and one cut by its edge, two captions over a bright picture.
- engines/README (HyperFrames, 画面文字检查): the contract and what it cannot see (text in images, WebGL or an offscreen canvas); playbook/02 layer 2: the method and the command; tools/ci.sh runs it on a fixture with one read per case, skipped where no Chrome is found.

**Lessons from four films go into the docs**
- Why: the maintainer asked that the general lessons kept in the agent's memory notes and in project LESSONS.md files move into the docs, leaving out what is personal to one channel. They come from 《发送之后》, a news film (FZ1073), the 3-minute tech-history film and the review rounds of 2026-10-04/05.
- playbook/09 §10 (new): how narration, title cards and labels read as written by a person. Let the fact carry the heaviest beat; vary sentence length; the sentence shapes models reach for (parallelism, triplets, "不是……而是……"); spoken words; explanations go into labels; one common way to end (from this case to this kind of case, back to the person) in plain words; a caption-only script goes out with the gate ② storyboard (or stops on its own when the human owns `script`). A default, overridden by a concept that wants another voice; any film with narration or cards reads it. `templates/SCRIPT.md` points there from both of its comments; the concept card's 首尾 line goes down to the last line or last picture (a 3-minute film changed its ending three times at gate ③).
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ OpenVideoHarness/
├── CLAUDE.md / AGENTS.md 入口(本文件;AGENTS.md 由 bin/vh sync-agents 生成,内容相同)
├── README.md / README.zh-CN.md 给人看的说明;CONTRIBUTING.md 是改本仓库时的规则;install.sh 一键安装
├── LOCAL.md 本机环境(不入库;模板是 LOCAL.example.md)
├── bin/vh 命令行:doctor · setup · types · effort · new · style · recipes · hf-init · install-skill · sync-agents · tts · voices · captions · beats · music · sfx · mix · mux · qa · readcheck · storyboard · rhythm · cover-preview · sheet · check · gif · review · desk
├── bin/vh 命令行:doctor · setup · types · effort · new · style · recipes · hf-init · install-skill · sync-agents · tts · voices · captions · beats · music · sfx · mix · mux · qa · readcheck · textcheck · storyboard · rhythm · cover-preview · sheet · check · gif · review · desk
├── tools/ bin/vh 背后的脚本;audio/README.md 是声音命令的参数手册,desk/README.md 是审阅台的读取约定;ci.sh 是仓库自检
├── skills/open-video-harness/ 轻量 skill:在任何目录把做视频的请求引到本仓库
├── video-types/ 9 类视频(09 实验中):工作流、审美、禁止项、Prompt 增量块、自查重点、案例
Expand Down
21 changes: 20 additions & 1 deletion bin/vh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
# FAIL under one brisk read (CJK/7 + other/20 + 0.8 s, at least 1.5 s; exit 1), WARN under the target of the BRIEF's
# Pace (exit 0); subtitles within reading speed. data-read="subtitle"|"label" and data-pace="…" mark clips
# readcheck --budget <s>: how many characters fit in a span · readcheck <index.html|project> --export [--out f]: the composition's timed text
# textcheck <project|index.html> [--texts f.json] [--min-visible 0.7] [--min-contrast 3] [--samples 3] [--scan-step 1] [--caption-step 0.5] [--caption-close 0.12] [--out dir] [--soft-gl] [--timeout 900]
# text drawn into a canvas, which npx hyperframes check cannot see: a read covered by the picture (under 70% of it
# shows), under 3:1, outside or cut by the frame, or under a DOM caption (exit 1); canvas text outside the registry
# and captions over a bright picture warn. The composition opts in with window.__vh = { draw(t), texts? } (engines/README.md)
# sheet <video> [cols] [fps] [out] contact sheet (default: <project>/out/check/<name>-sheet-<HHMMSS>.png)
# check <video> [--allow t,t…] [--against <ref.mp4>] ffprobe + the BRIEF's spec (size, rate, length; exit 1 on a mismatch) +
# black/freeze/silence detection + isolated-frame scan (one-frame capture glitches; exit 1 on any not declared
Expand Down Expand Up @@ -439,6 +443,21 @@ for l in rules.splitlines():
}

cmd_readcheck() { python3 "$ROOT/tools/readcheck.py" "$@"; }
hf_shell() { # the chrome-headless-shell binary itself (hf_chrome prints its version folder)
local p; p=$(hf_chrome) || return 1
[ -f "$p" ] && [ -x "$p" ] && { echo "$p"; return 0; }
p=$(find "$p" -maxdepth 3 -type f \( -name chrome-headless-shell -o -name chrome-headless-shell.exe \) 2>/dev/null | head -1)
[ -n "$p" ] && echo "$p"
}
cmd_textcheck() { # canvas text covered or too faint (tools/textcheck/textcheck.mjs). The browser: CHROME_PATH, HyperFrames'
# chrome-headless-shell (what renders the film), then Chrome or Chromium; no npm package needed
[ $# -gt 0 ] || { cmd_usage textcheck; exit 1; }
local b=${CHROME_PATH:-}
if [ -z "$b" ] || [ ! -x "$b" ]; then
b=$(hf_shell) || b=$(chrome_path) || { echo "$c_no bin/vh textcheck needs a headless Chrome: npx hyperframes browser ensure (in a HyperFrames project), install Chrome or Chromium, or set CHROME_PATH" >&2; exit 2; }
fi
exec node "$ROOT/tools/textcheck/textcheck.mjs" --browser "$b" "$@"
}
cmd_storyboard() { [ $# -gt 0 ] || { echo "usage: bin/vh storyboard <project> [--video v.mp4 | --snapshot] [--unsure S02,S04] [--per 6] (-h: the shots.json schema)"; exit 1; }
uv run -q --no-project --with pillow python "$ROOT/tools/storyboard.py" "$@"; }
cmd_rhythm() { [ $# -gt 0 ] || { echo "usage: bin/vh rhythm <project> [--segment NAME | --from s --to s] [--max s] [--out file.png]"; exit 1; }
Expand Down Expand Up @@ -758,7 +777,7 @@ case "${1:-help}" in
recipes) shift; python3 "$ROOT/tools/recipes.py" "$@" ;;
review) shift; cmd_review "$@" ;;
desk) shift; cmd_desk "$@" ;;
new) shift; cmd_new "$@" ;; style) shift; cmd_style "$@" ;; readcheck) shift; cmd_readcheck "$@" ;;
new) shift; cmd_new "$@" ;; style) shift; cmd_style "$@" ;; readcheck) shift; cmd_readcheck "$@" ;; textcheck) shift; cmd_textcheck "$@" ;;
storyboard) shift; cmd_storyboard "$@" ;; rhythm) shift; cmd_rhythm "$@" ;; cover-preview) shift; cmd_cover_preview "$@" ;; effort) shift; cmd_effort "$@" ;; sync-agents) cmd_sync_agents ;; hf-init) shift; cmd_hf_init "$@" ;; sheet) shift; cmd_sheet "$@" ;; check) shift; cmd_check "$@" ;; gif) shift; cmd_gif "$@" ;;
help|-h|--help) cmd_help ;;
*) { echo "unknown command: $1"; echo; cmd_help; } >&2; exit 1 ;;
Expand Down
18 changes: 18 additions & 0 deletions engines/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ HYPERFRAMES_RENDER_DETACHED=1 nohup caffeinate -i npx hyperframes render --quali

- **写一段,查一段**:`npx hyperframes lint` 到 0 error 再往下;`snapshot --at <秒> --describe false` 看几个关键时刻的单帧;`render --quality draft --fps 30 --output out/draft.mp4` 出草稿,字体和接缝以渲出来的 mp4 为准;最后 `--quality delivery` 出成片。完整写法见上面那一块命令。

### 画面文字检查(canvas 片子)

`npx hyperframes check` 只量 DOM 文字,脚本画进 `<canvas>` 的字在它眼里只是像素。canvas 片子用 `bin/vh textcheck <项目>` 查:哪条字被后画的东西盖住、和画面对比不够、出了画面或被边缘裁掉、被 DOM 字幕压住。合成的内联脚本里加一行就能用:

```js
window.__vh = {
draw: (t) => drawFrame(t), // 必填:画出第 t 秒,连同它自己管的 DOM(比如字幕),和渲染出的那一帧一样;可以返回 Promise
texts: TEXTS, // 可选:reads 登记表,格式同 readcheck 的 texts.json:[{text, start, end, read?}]
};
```

- **画字的函数不用改。** 检查在合成的脚本运行之前挂上 canvas 的 `fillText` / `strokeText`,记下每条字画在哪、用什么字体,能单独去掉一条字重画这一帧。所以字要用 2D canvas 的 `fillText` / `strokeText` 画:做成图片的、画进 WebGL 的、画在离屏 canvas 里再贴上来的字,它看不到,登记了会报"没画成 canvas 字"。字底下的"画面"是页面上所有 canvas、video、img 按层叠顺序合成的结果,字画在透明的一层上也按它后面那层量;CSS 背景、SVG 这类 DOM 不算在内。逐字出现的动画字、拆成几次画的一行字、中间的"·"和箭头画成图形的,都按字母对得上。
- **登记表**:命令行给了 `--texts <文件>` 就用它,否则用 `window.__vh.texts`,再没有就读 `out/texts.json`(readcheck 读的同一份)。每条在它的时间窗里取 3 个时刻(开始后 0.4 s 到结束前 0.3 s),所以 start 要是字完整显示的那一刻,这也是 readcheck 的约定;登记早了会报"有的时刻没画全"。没有登记表也能跑,只做下面那遍扫描。
- **没登记的字**每秒扫一遍全片,每行字在它最完整的那一刻量一次,有问题只警告:观众该读的,就登记进去。
- `draw(t)` 要是 t 的纯函数(硬规则 1):同一个 t 会画好几遍,两遍画面不一样会报出来,那一刻的数字不可信。
- **浏览器**:`CHROME_PATH`,否则用 HyperFrames 渲染用的 chrome-headless-shell(缓存为空时先 `npx hyperframes browser ensure`),再否则用系统的 Chrome 或 Chromium,不用装 npm 包。合成从项目目录起一个只在本机 127.0.0.1 的服务来打开,尺寸按根元素的 `data-width` × `data-height`,设备像素比 1;先等 `window.__hf.buildReady`、`window.__vh.ready`(可选)和字体加载完。
- **结果**:有登记的 read 被盖住(露出不到 70%)、低于 3:1、出画或被裁、压在字幕下,或画帧函数抛错,退出码 1;没登记的字、字幕底下的画面太亮只警告;没法检查(没有 `window.__vh.draw`、页面没加载起来、找不到浏览器、一条 canvas 字都没量到)是 2,不算通过。每处问题存一张裁图到 `out/check/textcheck/`,全部数字在同一目录的 `textcheck.json`;阈值都能调,见 `bin/vh textcheck -h`。一支 457 s、107 条登记文字的片子在 M3 Max 上约 13 s 跑完。

### 注意事项和已知问题(0.8.82 实测)

- **`snapshot` 一律带 `--describe false`。** 环境里有 `GEMINI_API_KEY`(或 `GOOGLE_API_KEY`)时,`snapshot` 默认把每一帧发给 Gemini 做画面描述,结果写进 `snapshots/descriptions.md`:画面离开本机,还要按 Gemini 计费。本仓库的 `bin/vh tts … --align gemini` 也要这个 key,所以 key 多半是设了的。`--describe false` 和 `--describe=false` 都能关掉;没设 key 时它只打一行 "skipping"。`check` 不受影响。
Expand Down
1 change: 1 addition & 0 deletions playbook/02-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
1. **能不能跑**:lint、编译、dry-run。Manim 的报错按 ScopeRefine 的顺序修:先改出错行前后 ±1 行,不行就改整个代码块,再不行就重写整个场景。
2. **不看图的结构检查**(确定性检验:从中间抽几帧单独渲染,和整片里的同一帧比对,要逐像素相同,或者 PSNR ≥ 45 dB;比的是无损 PNG 帧,不是编码后的 mp4,原因见下文"确定性";`ffmpeg -i a.png -i b.png -lavfi psnr -f null -`):
- 渲染器自带的检查,例如 HyperFrames `check` 会查 DOM 包围盒、文字溢出和对比度;
- canvas 里画的字,`check` 看不到:canvas 片子跑 `bin/vh textcheck <项目>`,合成里要有一行 `window.__vh`(`engines/README.md`"画面文字检查")。它把同一帧画两遍,一遍照常、一遍单独去掉这条字,再把这条字单独画一遍当掩膜:字自己的像素里,两遍不同的(或者正是字自己颜色的)占多少,就是露出来多少,不到七成算被盖住;字和底下画面的对比、和紧挨着它一圈(描边、阴影、底板都算)的对比,两个都低于 3:1 算看不清;出画、被边缘裁掉、压在 DOM 字幕下也算。没登记的 canvas 字和字幕底下的画面亮度另外扫一遍,只警告。一支 457 s 的片子这样扫了全部 106 条画面文字,找出 3 条对比度不够的,翻帧时都没看出来;
- 打印所有对象的包围盒,检查有没有重叠(SGA 的做法);
- 用像素统计找空白帧和卡住的帧:逐帧 YAVG 找突降,逐帧和第 0 帧比 PSNR,见下文"静默失败";
- 黑场、冻结、静音检测(命令见下文),孤立帧扫描(`bin/vh check` 一并做,见下文"静默失败");
Expand Down
37 changes: 35 additions & 2 deletions tools/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -752,12 +752,45 @@ PY
rm -rf "$t"
}

# bin/vh textcheck on its fixture (tools/textcheck/fixture/index.html: one read per case), with whatever headless Chrome
# the machine has (GitHub's runners come with Chrome); skipped without one
textcheck_checks() {
local t a rc
t=$(mktemp -d "${TMPDIR:-/tmp}/vh-ci.XXXXXX")
a=$(vh textcheck tools/textcheck/fixture --out "$t/out" 2>&1); rc=$?
case "$rc:$a" in 2:*"needs a headless Chrome"*) skip "textcheck" "no Chrome, Chromium or chrome-headless-shell"; rm -rf "$t"; return ;; esac
if [ $rc = 1 ] && python3 - "$t/out/textcheck.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1], encoding="utf-8"))
got = {r["text"]: (r["status"], sorted(f for f in ((r.get("worst") or {}).get("flags") or []) if f != "unstable")) for r in d["reads"]}
want = {"CLEAR": ("ok", []), "COVERED": ("fail", ["covered"]), "FAINT": ("fail", ["contrast"]), "OUTLINED": ("ok", []), "KINETIC": ("ok", []),
"TWO · PARTS": ("ok", []), "GONE": ("fail", ["outside"]), "HIDDEN": ("fail", ["caption"]), "LATE": ("ok", []), "NOWHERE": ("not drawn", [])}
bad = [f"{k}: got {got.get(k)}, want {v}" for k, v in want.items() if got.get(k) != v]
late = [r for r in d["reads"] if r["text"] == "LATE"]
if not late or late[0].get("missingAt") != [0.4]: bad.append(f"LATE: missing at {late[0].get('missingAt') if late else None}, want [0.4]")
flagged = [e["text"] for e in d["extra"] if e["flagged"]]
if flagged != ["ghost"]: bad.append(f"outside the registry: flagged {flagged}, want ['ghost']")
bright = [c["text"] for c in d["captions"] if c["worst"]["close"] > 0.12]
if bright != ["a caption over a bright band"]: bad.append(f"captions over a bright picture: {bright}")
print("\n".join(bad))
sys.exit(1 if bad else 0)
PY
then ok "textcheck: covered, faint, out-of-frame and captioned-over reads fail (exit 1); outlined, kinetic and split reads pass; a late read, a faint line outside the registry and a caption over a bright band warn"
else bad "textcheck on its fixture (exit $rc): $(printf '%s' "$a" | tail -c 1200)"; fi
# without the contract: nothing can be drawn on demand, so exit 2 and say what to add
sed 's/window.__vh = { draw, texts: TEXTS };//' tools/textcheck/fixture/index.html > "$t/index.html"
a=$(vh textcheck "$t" --out "$t/out2" 2>&1); rc=$?
case "$rc:$a" in 2:*"window.__vh = { draw"*) ok "textcheck without window.__vh.draw exits 2 and names the line to add" ;;
*) bad "textcheck without the contract: exit $rc: $(printf '%s' "$a" | tail -c 800)" ;; esac
rm -rf "$t"
}

case "${1:-all}" in
--smoke) smoke_checks; decision_checks; desk_checks ;;
--smoke) smoke_checks; decision_checks; desk_checks; textcheck_checks ;;
--committed) # exactly what a push would send: HEAD in a clean temporary checkout, uncommitted changes left out
w="$(mktemp -d "${TMPDIR:-/tmp}/vh-ci.XXXXXX")/head"; git worktree add -q --detach "$w" HEAD || exit 2
(cd "$w" && tools/ci.sh); rc=$?; git worktree remove --force "$w"; rmdir "$(dirname "$w")"; exit $rc ;;
all) static_checks; doc_checks; smoke_checks; decision_checks; desk_checks ;;
all) static_checks; doc_checks; smoke_checks; decision_checks; desk_checks; textcheck_checks ;;
*) echo "usage: tools/ci.sh [--smoke | --committed]"; exit 2 ;;
esac
[ $fails = 0 ] && ok "all checks passed" || printf '\033[31m%s check(s) failed\033[0m\n' "$fails"
Expand Down
Loading
Loading