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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

**Fixes after M1: no browser profile left by Ctrl-C, captions judged as subtitles, a slow Chrome start, an exported CDPATH**
- `tools/textcheck/browser.mjs`: Chrome's profile folder (about 17 MB) went only when the browser was closed. Ctrl-C, SIGTERM or SIGHUP on `bin/vh frames`, `textcheck`, `determinism` or `cues` left it in the temp folder, and so did `bin/vh render`'s own Ctrl-C handler (one per worker). Now every browser not closed yet is killed, with its helper processes (Chrome runs in a process group of its own), and its profile removed when node exits or gets one of those signals; a tool with its own handler keeps it, and the cleanup runs when that handler ends the process. The listeners are there only while a browser is open: after the last one closes, a Ctrl-C during synchronous work (`determinism` comparing its frames) ends the process at once, as before. Measured: a 3-worker 4K render stopped with Ctrl-C twice and `frames` once left no profile and no Chrome process (before: 2 profiles after a render, 1 after frames). After `kill -9` of node, Chrome still ends by itself when its pipe closes.
- The browser gets 60 s to answer instead of 30: a cold start on a busy CI runner once took longer (the same code passed on a rerun).
- `bin/vh readcheck <project>` reads the project's `audio/captions.json`, the narration's own words, and judged it by the on-screen rule unless given `--mode subtitle`: showcase 02 showed 15 FAILs of the wrong kind. Caption items (`zh` / `en`, no `text`) now go by the subtitle rule unless `--mode` names another; showcase 02's captions now show the 9 they really have (English lines over 20 characters a second, a few Chinese lines under 1.8 s). `playbook/03` says so.
- `bin/vh` unsets `CDPATH`: an exported one that holds a `bin` folder sent the `cd` that finds the repo (bin/vh run as `bin/vh`) elsewhere, and every command that reads a file from the repo failed. `tools/ci.sh`, `tools/fetch_media.sh` and `references/fetch.sh` do the same.
- CI: a project's captions go by the subtitle rule and `--mode` still decides; `bin/vh` under a CDPATH; `frames` stopped with Ctrl-C exits 130 and leaves no profile and no Chrome; `determinism` stopped with Ctrl-C while it compares exits 130 at once.

**`bin/vh textcheck` measures in the Chrome that renders the film**
- Why: on the maintainer's Mac (2026-10-11), `bin/vh textcheck` laid text out in HeadlessChrome 150.0.7871.24 from `~/.cache/puppeteer`, while `hyperframes render` drew the film in 152.0.7977.30 from `~/.cache/hyperframes/chrome`, and two Chrome versions can place text slightly differently. `bin/vh` looked in the puppeteer cache first because HyperFrames' default lookup does (`snapshot`, `check`), but `render` asks for its managed Chrome and never looks there.
- `bin/vh textcheck`: the browser is `CHROME_PATH`, else the chrome-headless-shell `render` uses for the project: `HYPERFRAMES_BROWSER_PATH` (or `PRODUCER_HEADLESS_SHELL_PATH`), else the version the project's own HyperFrames pins (read from its `node_modules`; on macOS 12 its macOS 12 pin) in `~/.cache/hyperframes/chrome`, or the newest one there when no pin can be read. Then the newest in `~/.cache/puppeteer`, then Chrome or Chromium, with a line saying text may lay out a little differently in the film. Version folders compare as numbers, and on macOS 12 majors above 150 are skipped (HyperFrames does so in the puppeteer cache and matches its own cache on the exact pin; skipping there too changes nothing).
Expand Down
1 change: 1 addition & 0 deletions bin/vh
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
# (exit 1 when the picture differs: a layout that breaks at device scale 2)
# gif <video> [width] [fps] palette-optimised GIF preview (for READMEs)
set -euo pipefail
unset CDPATH # an exported CDPATH would send a relative cd (this one: bin/vh run as bin/vh) to another folder, and print it
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
c_ok=$'\033[32m✓\033[0m'; c_no=$'\033[31m✗\033[0m'; c_wa=$'\033[33m!\033[0m'

Expand Down
2 changes: 1 addition & 1 deletion playbook/03-motion-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
- **字幕条**(跟着配音走):中文不超过 9 字/秒,半角字符算半个;英文不超过 20 字符/秒,空格和标点都算(两个数都是 Netflix 对成人节目的规定)。每条至少 1.8 s(lemo 的 `DIRECTOR.md` §7)。字幕的时长本来由配音决定,所以不跟 Pace;这条抓的是一句里塞了太多字。一句念得短于 1.8 s 时,`bin/vh captions` 把它的结束时间往后延进后面的空隙(见 `playbook/04-audio.md`)。
- **跟着旁白念的字幕,标 `data-read="subtitle"`**:画面上烧的字就是旁白的字,观众边听边核对,该按字幕的读速算,不该按画面文字算。HyperFrames 合成里给这样的片段标 `data-read="subtitle"`(标在片段上,或标在包着它的片段、场景上),`bin/vh readcheck index.html` 就用字幕的规则查它:输出里带 `sub` 标记,和别的文字一起算进总数。没标的仍按画面文字查,旁白没念的标题、标注、数字卡不要标;`data-read="onscreen"` 可以在被标的场景里把某一条改回去。导出的 `texts.json` 里是 `"read": "subtitle"`,手写的列表也认。
- 为什么字幕和画面文字不一样【综合】:字幕和声音同时到,观众是在核对刚听到的话,读速上限可以高(中文 9 字/秒);画面文字没有声音帮忙,观众要先在画面里找到它再读,所以底线用快读一遍的速度(7 字/秒),另加 0.8 s 找字。
- 检查:`bin/vh readcheck <texts.json | 项目 | index.html>`。Pace 从项目的 BRIEF 读,`--pace` 可以临时换一档;低于底线报 FAIL(退出 1),只低于目标报 WARN(退出 0);`--mode subtitle` 查字幕。输入是 `[{text, start, end}]`,`bin/vh captions` 写的 `captions.json` 可以直接用;它只看时间,字有没有被裁出画面要看联系表。从合成导出的 `texts.json` 要在全部场景就位、字定稿以后重新导出再跑:拿草稿时导出的字表跑出"全过",不作数。还没写字时,`bin/vh readcheck --budget <秒>` 算一段时间里放得下多少字。
- 检查:`bin/vh readcheck <texts.json | 项目 | index.html>`。Pace 从项目的 BRIEF 读,`--pace` 可以临时换一档;低于底线报 FAIL(退出 1),只低于目标报 WARN(退出 0);输入是 `[{text, start, end}]`;`bin/vh captions` 写的 `captions.json` 可以直接用,`bin/vh readcheck <项目>` 读的就是它,按字幕的规则查(给了 `--mode` 就按给的;canvas-film 项目读的是页面导出的 `out/texts.json`);它只看时间,字有没有被裁出画面要看联系表。从合成导出的 `texts.json` 要在全部场景就位、字定稿以后重新导出再跑:拿草稿时导出的字表跑出"全过",不作数。还没写字时,`bin/vh readcheck --budget <秒>` 算一段时间里放得下多少字。
- **为什么不写死秒数**【实测】:介绍片 v5 为了让每条标签都过旧规则 max(2.5 s, 汉字数 ÷ 4.5 + 其他字符数 ÷ 15 + 1.5 s),一轮轮拉长停留,片长 87.5 → 150.5 s,人看完说"文字显示后的停顿太长了……但也不是那种一闪而过的"。改成只给短标签读一遍的时间、逐帧量过只补真正一闪而过的那几条以后,片长 103 s,人认可了节奏。下一支 `studio` 片子做完,维护者又说"项目中关于字的节奏也不要写死间隔时间,有的时候太慢"(2026-10-04),于是改成现在的两层:底线只管读一遍(就是 v5 用的那条短标签规则),从容多少交给 Pace 和音乐。补出来的时间里音乐不能停:让配乐"屏息"(鼓撤掉)来配合停留,26 处下来整片像一直在卡顿,加长的小节应当照常往下走(`showcase/04-intro-film` 的 `js/tmap.js`、`tools/retime.py`)。
- 数据图表转场约 1s,分阶段进行(Heer & Robertson 2007)。
- 变形容器里的字(胶囊、卡片在形状之间变形):容器开始变形约 0.08s 后字再入场,下一次变形开始前约 0.1s 字先退场。字不跟着容器一起拉伸,也不在形状还没稳住时出现。
Expand Down
1 change: 1 addition & 0 deletions references/fetch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# bash references/fetch.sh <dir> 只处理一个(dir 是下表第二列,例如 lemo-opuscar)
# bash references/fetch.sh --neutralize 不联网,只对已拉取的仓库重新做一遍中和
set -euo pipefail
unset CDPATH # an exported one could send these relative cds elsewhere (as in bin/vh)
cd "$(dirname "$0")" && mkdir -p repos && cd repos
ARG=${1:-}

Expand Down
37 changes: 35 additions & 2 deletions tools/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# doc/CLI mismatch, and a CLI that exited 0 on typos. These checks keep those from coming back.
# Exit status: the number of failed checks (0 = all passed).
set -uo pipefail
unset CDPATH # an exported one could send this relative cd elsewhere (as in bin/vh)
cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 2
ROOT=$(pwd)
VH_BASH=${VH_BASH:-bash}
Expand Down Expand Up @@ -428,8 +429,20 @@ decision_checks() {
case "$rc:$b" in 0:*"OK b"*"pace brisk, --pace"*) ok "readcheck takes the pace from the BRIEF, and --pace overrides it" ;; *) bad "readcheck --pace brisk (rc $rc): $b" ;; esac ;;
*) bad "readcheck with a BRIEF Pace: $a" ;; esac
printf '%s' '[{"id":"x","text":"t","start":0,"end":2,"pace":"fast"}]' > "$tl/badpace.json"
vh readcheck "$tl/badpace.json" >/dev/null 2>&1; rc=$?; rm -rf "$tl"
vh readcheck "$tl/badpace.json" >/dev/null 2>&1; rc=$?
[ $rc = 2 ] && ok "readcheck refuses a pace that is not relaxed, normal or brisk" || bad "readcheck with a pace of \"fast\" exited $rc"
# a project's audio/captions.json (what bin/vh captions writes) is the narration: the subtitle rule, unless --mode says otherwise
mkdir -p "$tl/cap/audio"
printf '%s' '[{"id":"a","start":0,"end":2.4,"zh":["慢慢地说一句话"]},{"id":"b","start":2.4,"end":3.1,"zh":["这一句太短"]}]' > "$tl/cap/audio/captions.json"
a=$(vh readcheck "$tl/cap"); rc=$?; b=$(vh readcheck "$tl/cap" --mode onscreen)
case "$rc:$a" in 1:*"OK a·zh"*"need 1.80s"*"FAIL b·zh"*"readcheck (subtitle)"*)
case "$b" in *"readcheck (onscreen"*) ok "readcheck <project> judges its captions by the subtitle rule; --mode still decides" ;; *) bad "readcheck <project> --mode onscreen: $b" ;; esac ;;
*) bad "readcheck on a project's captions (rc $rc): $a" ;; esac
# an exported CDPATH must not move bin/vh's own cd (run as bin/vh, its folder is relative)
mkdir -p "$tl/cdpath/bin"
a=$(cd "$ROOT" && CDPATH="$tl/cdpath" "$VH_BASH" bin/vh effort standard 2>&1); rc=$?
[ $rc = 0 ] && printf '%s' "$a" | grep -q '人工关卡' && ok "bin/vh ignores an exported CDPATH" || bad "bin/vh under a CDPATH (exit $rc): $(printf '%s' "$a" | head -3)"
rm -rf "$tl"
printf '%s\n' '<html><body><div id="root" data-composition-id="m" data-start="0" data-duration="10">' \
'<section id="s" class="clip" data-start="0" data-duration="10"><h1 id="t">script-driven</h1></section>' \
'<div id="c1" class="clip" data-start="1" data-duration="4"><span>四个汉字</span></div>' \
Expand Down Expand Up @@ -926,7 +939,7 @@ PY
# the canvas-film engine's template (engines/canvas-film): two frames draw, each the same PNG drawn first and drawn after
# the other (12.8 s is inside a page turn); a cue whose word is gone stops it in ready at once, naming the shot and the
# cue; its reads and captions pass textcheck
local c="$ROOT/engines/canvas-film/template" s0
local c="$ROOT/engines/canvas-film/template" s0 pid left seen alive
a=$(vh frames "$c" --at 4.9,12.8 --out "$t/cf1" 2>&1); rc=$?
# a machine without the template's CJK faces (the Linux runner) draws in a fallback and says so, which is exit 1; that
# alone is fine here, as the frames are still compared
Expand Down Expand Up @@ -968,6 +981,26 @@ PY
a=$(vh readcheck "$t/cfr" 2>&1); rc=$?
if [ $rc = 0 ] && [ -s "$t/cfr/out/texts.json" ] && printf '%s' "$a" | grep -q 'subtitle'; then ok "canvas-film: readcheck <project> judges the page's reads and captions (window.film.texts, written to out/texts.json)"
else bad "readcheck on a canvas-film project (exit $rc): $(printf '%s' "$a" | tail -c 800)"; fi
# Ctrl-C while a page is loading: the browser's profile folder goes too (Node would otherwise die without cleaning up)
printf '%s\n' '<!doctype html><title>hang</title><script>window.film = { duration: 2, ready: new Promise(() => {}), frame() {} };</script>' > "$t/hang.html"
mkdir -p "$t/tmpd"
TMPDIR="$t/tmpd" "$VH_BASH" "$ROOT/bin/vh" frames "$t/hang.html" --at 1 --out "$t/hang-out" >/dev/null 2>&1 & pid=$!
for _ in $(seq 1 120); do ls -d "$t/tmpd"/vh-textcheck-* >/dev/null 2>&1 && break; sleep 0.25; done
seen=$(ls -d "$t/tmpd"/vh-textcheck-* 2>/dev/null | wc -l | tr -d ' ') # a browser was up: otherwise this tests nothing
sleep 1; kill -INT "$pid" 2>/dev/null; wait "$pid"; rc=$?; sleep 1
left=$(ls -d "$t/tmpd"/vh-textcheck-* 2>/dev/null | wc -l | tr -d ' '); alive=$(pgrep -f "$t/tmpd" | wc -l | tr -d ' ')
[ "$seen" -ge 1 ] && [ "$rc" = 130 ] && [ "$left" = 0 ] && [ "$alive" = 0 ] && ok "frames stopped with Ctrl-C ends (exit 130), leaving no browser profile and no Chrome behind" \
|| bad "frames after Ctrl-C: a browser seen $seen, exit $rc, $left profile folder(s) and $alive Chrome process(es) left"
pkill -KILL -f "$t/tmpd" 2>/dev/null
# Ctrl-C after the last browser closed (determinism comparing its frames, synchronous work): the process ends at once,
# not after the comparisons (a signal listener left in place would hold the signal until the event loop turned again)
printf '%s\n' '<!doctype html><canvas id="c" width="64" height="36"></canvas><script>const x = document.getElementById("c").getContext("2d");' \
'window.film = { duration: 2, frame(t) { x.fillStyle = `rgb(${Math.floor(Math.random() * 255)},90,${Math.floor(t * 100)})`; x.fillRect(0, 0, 64, 36); } };</script>' > "$t/rnd.html"
"$VH_BASH" "$ROOT/bin/vh" determinism "$t/rnd.html" --at "$(seq -s, 0.02 0.02 1.9 | sed 's/,$//')" --out "$t/rnd-out" >/dev/null 2>&1 & pid=$!
seen=0; for _ in $(seq 1 600); do if pgrep -P "$pid" -f psnr >/dev/null 2>&1; then seen=1; break; fi; kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
if [ $seen = 1 ]; then kill -INT "$pid" 2>/dev/null; wait "$pid"; rc=$?
[ "$rc" = 130 ] && ok "determinism stopped with Ctrl-C while comparing ends at once (exit 130)" || bad "determinism after Ctrl-C while comparing: exit $rc (it went on)"
else wait "$pid" 2>/dev/null; skip "Ctrl-C while determinism compares" "the comparisons were not caught running"; fi
rm -rf "$t"
}

Expand Down
1 change: 1 addition & 0 deletions tools/fetch_media.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# usage: tools/fetch_media.sh [--force] [word…] (run from anywhere; words filter the paths, e.g. "04-intro" or "styles")
# Exit status: 0 when every matching file is in place, 1 when a download or a checksum failed or nothing matched.
set -uo pipefail
unset CDPATH # an exported one could send this relative cd elsewhere (as in bin/vh)
cd "$(dirname "$0")/.." || exit 1
BASE=${VH_MEDIA_URL:-https://github.com/ZLHad/OpenVideoHarness/releases/download/media}
force=0; if [ "${1:-}" = "--force" ]; then force=1; shift; fi
Expand Down
2 changes: 1 addition & 1 deletion tools/film/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ process.on("exit", unlock);
const parts = path.join(path.dirname(outFile), `.${path.basename(outFile)}.parts`);
fs.rmSync(parts, { recursive: true, force: true }); fs.mkdirSync(parts, { recursive: true });
const encoders = new Set();
for (const [sig, code] of [["SIGINT", 130], ["SIGTERM", 143]]) process.once(sig, () => { // the lock goes on exit; a browser closes with its pipe
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 });
Expand Down
Loading
Loading