From 70c9a6fd0dc6e783d553baae29bd69e551c0f868 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:54:54 +0000 Subject: [PATCH] Add principles doc and a delivery gate (npm run verify) - docs/principles.md: the six principles behind the template (one time source, frames as pure functions, enter/breathe/exit, looks as clothing, one canvas for every format, nothing derived is hand-edited) and how deliverable / repeatable / stable maps onto this repo - tools/verify.mjs: types -> source consistency -> every scene renders at start/mid/end in every format -> two renders are byte-identical; --quick skips rendering. publish runs it before rendering (--no-verify to skip) - tests/test_sync.py: script.json <-> timings.json <-> audio <-> scene registry stay in step - CLAUDE.md, README and skills point at the principles and the gate Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hf6CivwFE1rx9Hf8pbNvaS --- .claude/skills/new-scene/SKILL.md | 4 +- .claude/skills/new-style-pack/SKILL.md | 2 +- .claude/skills/publish/SKILL.md | 2 + .claude/skills/review/SKILL.md | 2 +- CLAUDE.md | 1 + README.md | 9 ++- docs/principles.md | 80 ++++++++++++++++++++++ package.json | 1 + tests/test_sync.py | 38 +++++++++++ tools/publish.mjs | 3 + tools/verify.mjs | 95 ++++++++++++++++++++++++++ 11 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 docs/principles.md create mode 100644 tests/test_sync.py create mode 100644 tools/verify.mjs diff --git a/.claude/skills/new-scene/SKILL.md b/.claude/skills/new-scene/SKILL.md index 3feff54..05f6317 100644 --- a/.claude/skills/new-scene/SKILL.md +++ b/.claude/skills/new-scene/SKILL.md @@ -5,6 +5,8 @@ description: 在 explainer-kit 里新写一场讲解(旁白 + 画面)。用 # 写一场 +动手前先过一遍 `docs/principles.md` 的六条原理;遇到组件里没有现成答案的需求,从原理推(比如「要和某个词同步」就是原理 1:用 `w()` 取帧)。 + ## 1. 先写旁白,再想画面 旁白就是时间轴。在纸面上把文稿切成「一句话一个画面」,每个画面变化前放一个 `[[cue]]`: @@ -53,4 +55,4 @@ node tools/new-scene.mjs s10 --look paper --chapter "第十场 · 标题" --text ## 5. 交付前 -`npx tsc --noEmit` 通过,然后按 `review` skill 审一遍动态、声音和竖屏。 +`npm run verify` 通过,然后按 `review` skill 审一遍动态、声音和竖屏。 diff --git a/.claude/skills/new-style-pack/SKILL.md b/.claude/skills/new-style-pack/SKILL.md index b630c14..74c21a6 100644 --- a/.claude/skills/new-style-pack/SKILL.md +++ b/.claude/skills/new-style-pack/SKILL.md @@ -18,7 +18,7 @@ node tools/new-scene.mjs s10 --look chalk --chapter "第十场 · 黑板风" --t `new-pack` 生成 `src/chalk/index.tsx`:经过 `themed()` 的调色板、带缓慢光斑的背景、章节卡、`Title`/`Card` 组件、`Look`。 -## 3. 每个包都要满足 +## 3. 每个包都要满足(背后是 `docs/principles.md` 的原理 2、3、4) - 调色板必须经过 `themed('', {...}, {accent: [...], accent2: [...]})`,把主色挂到 accent 槽位,品牌色才能一键替换;颜色写 `#rrggbb`。 - 背景永远有一点动作(漂移光斑、滚动纹理、闪烁),不能整屏静止。 diff --git a/.claude/skills/publish/SKILL.md b/.claude/skills/publish/SKILL.md index 41b0a81..5febf78 100644 --- a/.claude/skills/publish/SKILL.md +++ b/.claude/skills/publish/SKILL.md @@ -14,6 +14,8 @@ node tools/publish.mjs --skip-tts # 只改了画面,沿用现有配 node tools/publish.mjs --mock # 离线跑通整条流水线(静音旁白) ``` +publish 会在渲染前先跑 `tools/verify.mjs`(验收不过就停,不会白渲染几十分钟);确实要跳过用 `--no-verify`。 + 成品:`out/-mixed.mp4`、`out/-9x16-mixed.mp4`…… ## 出片前确认 diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 1f84b0e..68b3b3e 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -44,7 +44,7 @@ node tools/listen.mjs s05:block s05:out:30 # 一段 ## 交付前的检查清单 -- `npx tsc --noEmit`、`pytest` 通过 +- `npm run verify` 全部通过(类型、源文件一致、每场每种画幅可渲染、两次渲染逐字节一致) - 改过的每场都拼帧看过,主画幅和 9x16 各一遍 - 音效位置用 `listen` 核对过 - 真配音(Windows 上 `python tts/gen.py`)后再看一遍:cue 位置会变,写死的帧数会错位 diff --git a/CLAUDE.md b/CLAUDE.md index 88e0d84..ae73d45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,5 +4,6 @@ Remotion 讲解视频模板:旁白里写 `[[cue]]` 标记,edge-tts 的逐词 - 面向用户的文档用简体中文;代码、命令、标识符用英文。 - 本机是 Windows(渲染、真 TTS 验证在那里做);云端沙箱只做重构、类型检查、`--mock` 时间轴和单测。沙箱里也能看画面和听音效:`tools/` 的渲染脚本会自动用 `/opt/pw-browsers` 预装的 headless shell(`node tools/strip.mjs s01:quote`、`node tools/overview.mjs`、`node tools/listen.mjs s05`),只看,不提交 `out/`。 +- 做事的原则:往原理去理解,随后一通百通;可交付、可重复、稳定输出,才是 AI 赋能的前提。项目的六条原理见 `docs/principles.md`,动手前先对照它想清楚,新需求优先从原理推,不要只套组件。交付前跑 `npm run verify`(`--quick` 只做类型和一致性检查),它不过就不算完成。 - 常做的事有项目 skill(`.claude/skills/`):`new-scene` 写一场、`review` 审片、`new-style-pack` 新风格包、`publish` 出片。反复手动做的步骤,优先收成 `tools/` 脚本或补进这些 skill。 - 不在仓库里提交字体文件和渲染产物(`out/`、`public/fonts/`)。 diff --git a/README.md b/README.md index 3461d62..5f094e1 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 仓库自带一支约 3 分钟的演示片(9 场),每场换一套风格包:`film`(电影)、`slides`(幻灯片 + KaTeX 公式)、`paper`(手绘笔记)、`neon`(霓虹终端)、`editorial`(杂志排版)、`math`(数学推导)、`keynote`(发布会)、`pixel`(像素游戏)、`ink`(水墨)。演示片的时间轴和音频是用 `--mock` 生成的静音占位,克隆下来不用联网合成就能预览。 +先读 [`docs/principles.md`](docs/principles.md):六条原理讲清楚这个模板为什么这样设计,看懂它,后面的组件和工具都能推出来。 + ## 快速开始 ```bash @@ -175,6 +177,8 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 6. **配乐**:`python tools/music.py` → `out/music.wav`。按 timings 每场一段和声,每个章节开头一声轻击,重配音后重跑即可对齐。 7. **混音**:`python tools/mix.py` → `out/-mixed.mp4`(竖屏 `python tools/mix.py 9x16`)。配乐低通后按旁白做 sidechain 压缩,再整体 `loudnorm=I=-16:TP=-1.5:LRA=11`。 +交付前:`npm run verify`。依次检查类型、源文件一致(旁白稿 ↔ 时间轴 ↔ 音频 ↔ 登记表 ↔ 音效库)、每场在每种画幅下都能渲染、同一批帧渲染两次逐字节一致。`--quick` 只跑前两项;示意图在 `out/verify/`。 + 第 2、5、6、7 步可以一条命令跑完:`node tools/publish.mjs`(`--formats 9x16,1x1` 或 `all` 同时出其他画幅,`--skip-tts` 沿用现有配音,`--mock` 离线跑通流程)。 ## 画幅和品牌 @@ -191,7 +195,8 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 |---|---| | `node tools/new-scene.mjs s10 --look paper --chapter "…" --text "[[a]]……"` | 追加旁白、生成能直接跑的场景文件(每个 cue 一段跟着旁白出现的字)、登记、跑 mock 时间轴 | | `node tools/new-pack.mjs chalk --base "#1F2B26" --accent "#F2C14E"` | 生成一个新风格包骨架:接好品牌色的调色板、会动的背景、章节卡、组件、Look | -| `node tools/publish.mjs --formats all` | 配音 → 各画幅渲染 → 配乐 → 混音 | +| `node tools/publish.mjs --formats all` | 配音 → 验收 → 各画幅渲染 → 配乐 → 混音 | +| `npm run verify` | 交付前的验收:类型、源文件一致、每场可渲染、渲染结果可复现 | | `node tools/strip.mjs` / `overview.mjs` / `listen.mjs` | 审动态 / 看全片 / 听音效 | `.claude/skills/` 里有四个项目 skill,在 Claude Code 里打开这个仓库就能用:`new-scene`(从文稿到一场)、`review`(审片清单)、`new-style-pack`(新风格包的要求)、`publish`(出片和踩过的坑)。 @@ -266,7 +271,7 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 explainer-kit is a Remotion template for narrated explainer videos. Narration lives in `tts/script.json` with inline markers: `[[cue]]` marks an animation/shot trigger, `||` inserts a dramatic pause, and `<>` reads a quote with an alternate voice. `tts/gen.py` synthesizes each scene with edge-tts, records word boundaries and writes `public/audio/.mp3` plus `src/timings.json`. Scenes read frames through `useScene()` — `c('cue')`, `w('word')`, `rel('word', 'cue')` — so durations and animations follow the audio after any rewrite. -Quick start: `npm i && npm run studio` shows the bundled ~3 min, nine-scene demo (one scene per style pack), whose timings and silent audio were produced by `python tts/gen.py --mock` (no network; word times estimated from character count). `npm run fonts` downloads the Noto Sans SC / Noto Serif SC variable fonts (OFL) into `public/fonts/`; without them the stacks fall back to system fonts. All size, fps, voice, pace and font settings live in `kit.config.json`. Nine optional style packs are included: `film` (letterbox, grain, grade, cue-driven shots, paper props), `slides` (panels, chips, karaoke subtitles), `paper` (hand-drawn strokes that draw on and boil, sticky notes, highlighter), `neon` (scrolling grid floor, scanlines, glowing type, terminal, node graph with flowing pulses, glitch), `editorial` (masked kinetic headlines, slammed numbers, colour-block wipes, ticker), `math` (Manim-style axes, traced plots, sliding tangent, step-by-step TeX), `keynote` (one morphing shape, cursor clicks, liquid glass, screen-studio zoom), `pixel` (320×180 canvas upscaled with hard pixels, walking hero, coins, RPG dialog) and `ink` (ink-wash mountains bleeding through mist, brush strokes, vertical calligraphy, red seal). `src/core/motion.ts` holds a shared motion vocabulary (enter/exit with `life` + `move`, springs, idle `drift`, `punch`, `shake`), `` reveals on-screen text exactly as the narrator says it, and `Shots` cuts on cues with `fade`/`push`/`up`/`zoom`/`wipe`/`whip`/`iris`/`black`/`flash` transitions. Sound effects are synthesized by `tools/sfx.py` into `public/sfx/` and placed with `` (moving `Shots` transitions add their own); the score pulses on a `music.bpm` grid that scenes can follow with `useBeat()`. `brand.accent`/`accent2` in `kit.config.json` recolour every pack, `theme.` overrides single keys, and `formats` adds portrait (9:16) and square (1:1) compositions that lay the 16:9 picture out with a title band, large captions and a progress bar. `tools/publish.mjs` runs TTS → render (all formats) → score → mix in one go; `tools/new-scene.mjs` and `tools/new-pack.mjs` scaffold scenes and style packs; `.claude/skills/` holds Claude Code skills for writing a scene, reviewing, building a pack and publishing. Review with `tools/stills.mjs`, `tools/pick.mjs`, `tools/sheet.py`, `tools/strip.mjs` (a filmstrip of frames around a cue, to judge motion rather than end states), `tools/overview.mjs` (one frame per scene) and `tools/listen.mjs` (renders a range's audio and lists sound events by cue); render with `npm run render` (uses `--gl=angle`); add a synthesized score with `tools/music.py` and mix/normalize to -16 LUFS with `tools/mix.py`. +Quick start: `npm i && npm run studio` shows the bundled ~3 min, nine-scene demo (one scene per style pack), whose timings and silent audio were produced by `python tts/gen.py --mock` (no network; word times estimated from character count). `npm run fonts` downloads the Noto Sans SC / Noto Serif SC variable fonts (OFL) into `public/fonts/`; without them the stacks fall back to system fonts. All size, fps, voice, pace and font settings live in `kit.config.json`. Nine optional style packs are included: `film` (letterbox, grain, grade, cue-driven shots, paper props), `slides` (panels, chips, karaoke subtitles), `paper` (hand-drawn strokes that draw on and boil, sticky notes, highlighter), `neon` (scrolling grid floor, scanlines, glowing type, terminal, node graph with flowing pulses, glitch), `editorial` (masked kinetic headlines, slammed numbers, colour-block wipes, ticker), `math` (Manim-style axes, traced plots, sliding tangent, step-by-step TeX), `keynote` (one morphing shape, cursor clicks, liquid glass, screen-studio zoom), `pixel` (320×180 canvas upscaled with hard pixels, walking hero, coins, RPG dialog) and `ink` (ink-wash mountains bleeding through mist, brush strokes, vertical calligraphy, red seal). `src/core/motion.ts` holds a shared motion vocabulary (enter/exit with `life` + `move`, springs, idle `drift`, `punch`, `shake`), `` reveals on-screen text exactly as the narrator says it, and `Shots` cuts on cues with `fade`/`push`/`up`/`zoom`/`wipe`/`whip`/`iris`/`black`/`flash` transitions. `docs/principles.md` explains the six principles behind the design (one time source, frames as pure functions, enter/breathe/exit, looks as clothing, one canvas for all formats, nothing derived is hand-edited); `npm run verify` is the delivery gate (types, source consistency, every scene renders in every format, two renders are byte-identical) and `publish` runs it before rendering. Sound effects are synthesized by `tools/sfx.py` into `public/sfx/` and placed with `` (moving `Shots` transitions add their own); the score pulses on a `music.bpm` grid that scenes can follow with `useBeat()`. `brand.accent`/`accent2` in `kit.config.json` recolour every pack, `theme.` overrides single keys, and `formats` adds portrait (9:16) and square (1:1) compositions that lay the 16:9 picture out with a title band, large captions and a progress bar. `tools/publish.mjs` runs TTS → render (all formats) → score → mix in one go; `tools/new-scene.mjs` and `tools/new-pack.mjs` scaffold scenes and style packs; `.claude/skills/` holds Claude Code skills for writing a scene, reviewing, building a pack and publishing. Review with `tools/stills.mjs`, `tools/pick.mjs`, `tools/sheet.py`, `tools/strip.mjs` (a filmstrip of frames around a cue, to judge motion rather than end states), `tools/overview.mjs` (one frame per scene) and `tools/listen.mjs` (renders a range's audio and lists sound events by cue); render with `npm run render` (uses `--gl=angle`); add a synthesized score with `tools/music.py` and mix/normalize to -16 LUFS with `tools/mix.py`. ## License diff --git a/docs/principles.md b/docs/principles.md new file mode 100644 index 0000000..73cb410 --- /dev/null +++ b/docs/principles.md @@ -0,0 +1,80 @@ +# 原理 + +> 往原理去理解,随后一通百通;可交付、可重复、稳定输出,才是 AI 赋能的前提。 + +这份文档讲 explainer-kit 为什么是现在这个样子。只记组件用法,换一个需求就得重新问;把下面几条原理弄明白,新需求多半能自己推出来,也能判断 AI 写的代码对不对。 + +## 一条主线 + +``` +旁白稿 ──gen.py──▶ 逐词时间戳 ──timeline.ts──▶ 帧号 ──场景(纯函数)──▶ 画面 + [[cue]] timings.json c() w() rel() f ↦ 像素 +``` + +整个项目只做一件事:**把「说到哪」翻译成「第几帧该是什么样」**。下面每条原理,都是这条主线在某个方面的要求。 + +## 六条原理 + +### 1. 时间只有一个来源:旁白 + +画面不自己计时,只问「这个 cue 在第几帧」「这个词在第几帧」。秒数只在 `gen.py` 产生,经 `timings.json` 进入画面。 + +- **所以**:改一句旁白、换一个音色,重跑配音,所有动画和字幕自动对上。写死的帧数会错位,按 cue/词取帧的不会。 +- **推论**:屏幕上的大字也该跟着声音走,而不是按固定速度打字,所以有 ``。音效、配乐节拍也挂在同一条时间线上(``、`useBeat()`)。 + +### 2. 每一帧是帧号的纯函数 + +同一个帧号,永远画出同一张图。不能用 `Math.random()`、当前时间,也不能把状态留到下一帧;需要随机就用 `rng(seed)`、`noise(seed, x)`。 + +- **所以**:渲染可以多进程乱序并行;审片工具可以直接跳到任意一帧(`strip`、`overview` 都靠 `` 冻结帧);两次出片结果一致。 +- **验证**:`npm run verify` 的第 4 关,把同一批帧渲染两次,逐字节比较。 + +### 3. 动 = 进场 + 停留时的呼吸 + 退场 + +所有动效都是「一个 0→1 的进度」映射成样式:`tween` 算进度,`life(f, at, out)` 给出进场 `p` 和退场 `q`,`move(kind, p, q)` 变成 CSS。 + +- **所以**:画面问题都能归到这三段。死帧是停留阶段没有动作,加 `drift` 或让背景自己动;重影和重叠是旧内容没退场,补上 `out`;生硬是缺进度曲线,换 `EASE` 或弹簧。 +- **推论**:能变形就别切(keynote 的 `Morph`)、容器先动内容后进、一个镜头最多停 1 秒,这些动效规则都是这一条的具体写法。 + +### 4. 风格是外面那层衣服,核心不认识风格 + +场景只描述「什么时候出现什么」;底色、背景、叠加层、章节卡、字幕样式由 `Look` 统一套上;颜色经 `themed()` 暴露主色槽位。`src/core` 从不 import 任何风格包。 + +- **所以**:一场戏换一套风格,只改登记表里的 `look`;换品牌色只改 `kit.config.json`;删掉不用的风格包,其他代码不受影响。 +- **推论**:新风格包一定要有 `themed()` 调色板、会动的背景、章节卡和 `Look`,`new-pack` 生成的骨架就是这四样。 + +### 5. 只在一张画布上设计,画幅交给外壳 + +场景永远按 1920×1080 写。竖屏和方屏由 `Video` 外壳排版:中间放画面,上面是标题,下面是大字幕。 + +- **所以**:多一种画幅不用改任何场景;某场想在竖屏里放大,只在登记表里加 `portrait: {zoom}`。 +- **代价**:竖屏里画面偏小。要做原生竖屏,才需要按画幅单独写场景,这是有意的取舍。 + +### 6. 能由源文件推出来的,都不手改 + +`timings.json` 由 `gen.py` 生成,`public/sfx` 由 `sfx.py` 生成,配乐由 `music.py` 生成,成片由 `publish.mjs` 一条命令产出。配置只在 `kit.config.json` 一处。 + +- **所以**:任何人、任何一台机器,拿到同一份仓库,就能出同一支片子。 +- **验证**:`tests/test_sync.py` 检查旁白稿、时间轴、音频、登记表是否一致;`tests/test_sfx.py` 检查音效名、合成配方和文件是否一致。 + +## 可交付、可重复、稳定输出 + +| | 在这个项目里的意思 | 靠什么保证 | +|---|---|---| +| **可交付** | 一条命令从仓库出成片,不依赖记忆和手工步骤 | `node tools/publish.mjs`;`new-scene` / `new-pack` 生成能直接跑的代码 | +| **可重复** | 同样的输入,得到同样的输出 | 原理 2(纯函数)+ 原理 6(生成物有源);素材全部由代码合成,依赖版本锁定 | +| **稳定输出** | 每次交付前都过同一道关,不靠运气 | `npm run verify`:类型 → 源文件一致 → 每场在每种画幅都能渲染 → 两次渲染逐字节一致;`publish` 渲染前会自动跑一遍 | + +AI 写代码很快,但「快」只有落在这三条上才算数。这三条立住了,AI 加的每个功能都能被验收、能复现、能交给别人用。立不住,它每次都要从头猜一遍。 + +## 遇到新需求,先用原理推一遍 + +| 需求 | 从原理推 | +|---|---| +| 「这个图标要在说到某个词时亮起来」 | 原理 1:`w('那个词')` 拿帧号,`tween(f, 那一帧)` 做进度 | +| 「这段太平了」 | 原理 3:哪一段是空的?停留阶段加呼吸,切换时换一种进场方式或转场 | +| 「出一版抖音竖屏」 | 原理 5:场景不用改,`npm run render -- 9x16`;内容太小就加 `portrait.zoom` | +| 「客户要用他们的品牌色」 | 原理 4:`brand.accent`,个别颜色用 `theme.` 微调 | +| 「渲染出来偶尔不一样」 | 原理 2:找随机数、时间、跨帧状态;`verify` 第 4 关会告诉你 | +| 「换了配音后动画错位」 | 原理 1:某处写死了帧数,改成 `c()` / `w()` / `rel()` | +| 「又在手动重复同一串操作」 | 原理 6:收成 `tools/` 脚本或补进 `.claude/skills/` | diff --git a/package.json b/package.json index 77e5a14..debe95f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "publish": "node tools/publish.mjs", "new:scene": "node tools/new-scene.mjs", "new:pack": "node tools/new-pack.mjs", + "verify": "node tools/verify.mjs", "typecheck": "tsc --noEmit", "test": "pytest" }, diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..afaace0 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,38 @@ +"""The film is reproducible only if every derived file matches its source. These checks fail fast when they drift.""" +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CUE = re.compile(r"\[\[(\w+)\]\]") + + +def load(): + script = json.loads((ROOT / "tts" / "script.json").read_text(encoding="utf-8"))["scenes"] + timings = json.loads((ROOT / "src" / "timings.json").read_text(encoding="utf-8")) + return script, timings + + +def test_timings_match_script(): + """src/timings.json is generated from tts/script.json — same scenes, order, chapters and cues (else rerun tts/gen.py)""" + script, timings = load() + assert [s["id"] for s in script] == list(timings), "scene list/order differs: rerun python tts/gen.py (--mock)" + for s in script: + t = timings[s["id"]] + assert t["chapter"] == s["chapter"], f"{s['id']}: chapter changed since the last tts/gen.py run" + assert set(CUE.findall(s["text"])) == set(t["cues"]), f"{s['id']}: cues changed since the last tts/gen.py run" + + +def test_audio_files_exist(): + _, timings = load() + for sid, t in timings.items(): + assert (ROOT / "public" / t["audio"]).exists(), f"{sid}: public/{t['audio']} missing" + + +def test_every_scene_is_registered(): + """a scene in the script but not in src/scenes/index.ts renders as empty frames""" + script, _ = load() + reg = (ROOT / "src" / "scenes" / "index.ts").read_text(encoding="utf-8") + registered = set(re.findall(r"^\s+(\w+): \{component:", reg, re.M)) + missing = [s["id"] for s in script if s["id"] not in registered] + assert not missing, f"not registered in src/scenes/index.ts: {missing}" diff --git a/tools/publish.mjs b/tools/publish.mjs index 4df3e39..786a580 100644 --- a/tools/publish.mjs +++ b/tools/publish.mjs @@ -3,6 +3,7 @@ // node tools/publish.mjs --formats 9x16,1x1 → also the portrait / square cuts (or --formats all) // node tools/publish.mjs --skip-tts → keep the current audio + timings (e.g. after a visual-only edit) // node tools/publish.mjs --mock → offline timings and silent narration (pipeline check) +// node tools/publish.mjs --no-verify → skip the delivery gate (tools/verify.mjs) before rendering // Long renders: run it detached and watch the log (see README 踩过的坑), e.g. // nohup node tools/publish.mjs --formats all > out/publish.log 2>&1 & import {spawnSync} from 'node:child_process'; @@ -39,6 +40,8 @@ const run = (label, cmd, args) => { }; if (!has('--skip-tts')) run('narration', py, ['tts/gen.py', ...(has('--mock') ? ['--mock'] : [])]); +// same gate every time: a broken scene or a non-deterministic frame stops here, not 40 minutes into a render +if (!has('--no-verify')) run('verify', 'node', ['tools/verify.mjs']); for (const f of [undefined, ...fmts]) run(`render ${f ?? 'main'}`, 'node', ['tools/render.mjs', ...(f ? [f] : [])]); run('score', py, ['tools/music.py']); for (const f of [undefined, ...fmts]) run(`mix ${f ?? 'main'}`, py, ['tools/mix.py', ...(f ? [f] : [])]); diff --git a/tools/verify.mjs b/tools/verify.mjs new file mode 100644 index 0000000..83d8f65 --- /dev/null +++ b/tools/verify.mjs @@ -0,0 +1,95 @@ +// Delivery gate: the same checks before every hand-off, so output is reproducible and stable, not lucky. +// usage: node tools/verify.mjs → everything below +// node tools/verify.mjs --quick → only 1–2 (no rendering) +// +// 1. types npx tsc --noEmit +// 2. sources pytest: script.json ↔ timings.json ↔ audio ↔ scene registry ↔ sound library, parser rules +// 3. every scene renders at its start, middle and end in every format (a missing cue/word throws here, not mid-render) +// 4. determinism the same frames rendered twice are byte-identical (no Math.random, no clock, no leftover state) +// Writes out/verify/*.png for a look; exits non-zero on the first failing stage. +import {spawnSync} from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import {renderStill, selectComposition} from '@remotion/renderer'; +import {CFG, IDS, browserExecutable, leadOf, narrEnd, open, root, starts} from './timeline.mjs'; + +const quick = process.argv.includes('--quick'); +const outDir = path.join(root, 'out/verify'); +fs.mkdirSync(outDir, {recursive: true}); +const py = spawnSync('python', ['--version']).status === 0 ? 'python' : 'python3'; +const results = []; +const stage = async (name, fn) => { + const t = Date.now(); + console.log(`▶ ${name}`); + try { + const note = await fn(); + results.push([name, true, note ?? '']); + console.log(` ✔ ${((Date.now() - t) / 1000).toFixed(0)} s${note ? ` ${note}` : ''}`); + } catch (e) { + results.push([name, false, String(e.message ?? e)]); + console.log(` ✖ ${e.message ?? e}`); + summary(); + process.exit(1); + } +}; +const run = (cmd, args) => { + const r = spawnSync(cmd, args, {cwd: root, encoding: 'utf8', shell: process.platform === 'win32'}); + if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed:\n${(r.stdout ?? '') + (r.stderr ?? '')}`.trim()); + return r.stdout ?? ''; +}; +const summary = () => { + console.log('\nverify:'); + for (const [n, ok, note] of results) console.log(` ${ok ? '✔' : '✖'} ${n}${note && ok ? ` (${note})` : ''}`); +}; + +await stage('1 types', () => { + run('npx', ['tsc', '--noEmit']); +}); +await stage('2 sources', () => { + const out = run(py, ['-m', 'pytest', '-q']); + return out.trim().split('\n').pop(); +}); + +if (!quick) { + const spots = IDS.flatMap((id) => { + const a = leadOf(id); + const b = narrEnd(id); + return [a, Math.round((a + b) / 2), b - 1].map((fr) => starts[id] + fr); + }); + const labels = IDS.flatMap((id) => ['start', 'mid', 'end'].map((k) => `${id} ${k}`)); + const id = `${CFG.id}-strip`; + const {serveUrl} = await open(id, {frames: [0]}); + const still = async (inputProps, output) => { + const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable}); + await renderStill({composition, serveUrl, output, inputProps, browserExecutable}); + return output; + }; + + for (const fmt of [undefined, ...Object.keys(CFG.formats ?? {})]) { + await stage(`3 every scene renders${fmt ? ` (${fmt})` : ''}`, async () => { + // chunks of 12 tiles keep each image readable + for (let i = 0, k = 0; i < spots.length; i += 12, k++) { + const frames = spots.slice(i, i + 12); + const cols = fmt && CFG.formats[fmt].height > CFG.formats[fmt].width ? 6 : 4; + await still({frames, labels: labels.slice(i, i + 12), cols, format: fmt}, path.join(outDir, `scenes${fmt ? `_${fmt}` : ''}_${k}.png`)); + } + return `${IDS.length} scenes × 3 frames`; + }); + } + + await stage('4 determinism', async () => { + // one frame mid-narration per scene, rendered twice in separate passes + const frames = IDS.map((id) => starts[id] + Math.round((leadOf(id) + narrEnd(id)) / 2)); + const hash = async (tag) => { + const p = await still({frames, cols: 4}, path.join(outDir, `det_${tag}.png`)); + return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + }; + const [a, b] = [await hash('a'), await hash('b')]; + if (a !== b) throw new Error('the same frames rendered differently twice — look for Math.random(), Date, or state kept between frames (out/verify/det_a.png vs det_b.png)'); + return 'identical'; + }); +} + +summary(); +console.log(quick ? '\n(quick: rendering skipped)' : `\nimages: ${path.relative(root, outDir)}/`);