explainer-video 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +69 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  24. package/skills/explainer-video-from-codebase/SKILL.md +659 -0
  25. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  26. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  27. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  28. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  29. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  30. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  31. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  32. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  33. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  35. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  37. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  38. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  45. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  59. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  60. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  61. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  62. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  63. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  81. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  88. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  89. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  106. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
  107. package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
  108. package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
  109. package/skills/explainer-video-from-coursework/SERIES.md +232 -0
  110. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  111. package/skills/explainer-video-from-coursework/SKILL.md +858 -0
  112. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  118. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  120. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  121. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  122. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  124. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  125. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  126. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  127. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  128. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  129. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  130. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  131. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  132. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  133. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  134. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  135. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  136. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  137. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  138. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  139. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  140. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  141. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  142. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  143. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  144. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  145. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  146. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  147. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  148. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  149. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  150. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,858 @@
1
+ ---
2
+ name: explainer-video-from-coursework
3
+ description: Plan an explainer-video series from a directory of coursework notes — decompose into videos (proving nothing is dropped), retitle and curate, write the narration, synthesize the audio, author the visuals, then render and assemble the finished cut. Use when the user wants to turn structured coursework notes into explainer videos.
4
+ ---
5
+
6
+ # explainer-video-from-coursework — plan, enrich, script, audio, scene, publish, render, assemble
7
+
8
+ Turn a notes directory into a **video series**. Stage 0 gathers the source and the
9
+ destination; stages 1–2 run once over the whole directory and plan the series (the
10
+ planner decomposes and proves completeness; you retitle and curate); stages 3–8 then
11
+ run **once per video** — write the narration, TTS turns it into measured audio, you
12
+ author the visuals, generate the publishing metadata, then render and assemble that
13
+ video's finished cut.
14
+
15
+ The skill's own files (`tools/`, `src/`, `scenes/`) live in **this skill's folder** — resolve
16
+ `tools/<tool>.mjs` against it; the tools then self-locate everything else, so they run
17
+ regardless of cwd. The eight `craft-video-<stage>` skills sit beside this one under `skills/`.
18
+ But the work and scratch paths (`out/`, `--work`, `--out`, `<notesDir>`) resolve against the
19
+ **current directory**, so run every command from the **scratch root** — the directory that
20
+ holds `out/`. Stage 0 fixes it once, defaulting beside the source. Never run from the skill's
21
+ install folder, and never from whatever directory you happen to be in: the wrong root
22
+ scatters a few hundred MB of scratch somewhere no `.gitignore` covers and nobody will think
23
+ to look.
24
+
25
+ > **For maintainers — the dual-file rule.** This skill is deliberately **monolithic**:
26
+ > the whole pipeline lives here, so a full run loads in one pass. Every stage *also* ships
27
+ > as its own sibling skill — `craft-video-<stage>/SKILL.md`, one per stage — a **complete
28
+ > contract** for that stage: its prerequisites, its command, what the agent writes, its
29
+ > hard checks and its `done when` gate. Those siblings are top-level under `skills/`, so
30
+ > every platform discovers them and any single stage can be re-run alone. Treat **this file
31
+ > as the contract of record**: every stage skill ends with a **Maintenance** note requiring
32
+ > its stage's section here to be reconciled whenever it changes. **If you add a stage, or
33
+ > change an existing one, update *both*** — the stage's section here *and* its sibling
34
+ > skill — or they will silently diverge.
35
+ > Stage skills reach the tools as `../explainer-video-from-coursework/tools/<tool>.mjs`;
36
+ > keep that path in step if the layout moves again. Cross-stage behaviour such as the
37
+ > `/scene ↔ /audio` loop lives in a tool, never in either file, so it stays in one place.
38
+ > See `ROADMAP.md` for the design record.
39
+
40
+ ## Prerequisites
41
+
42
+ One-time machine setup before the first run — Node ≥ 22, `ffmpeg`/`ffprobe`, the headless
43
+ Chromium, and the Gemini API key. The commands and per-OS store steps live in
44
+ [`SETUP.md`](./SETUP.md) in this folder.
45
+
46
+ **Relay rule.** The tools fail loud on a missing prerequisite — `[node] …`, `[setup] …`,
47
+ `Playwright cache not found …`, or `No API key found …`. When one appears, do not debug the
48
+ scene: tell the user what is missing and the install steps from the matching `SETUP.md`
49
+ section, then re-run.
50
+
51
+ ### Series scope — the per-video loop
52
+
53
+ - **Stages 1–2 run once.** They produce the plan for the whole directory; nothing
54
+ below this point iterates the video list.
55
+ - **Stages 3–8 repeat once per video you ship.** Give each video its own work
56
+ directory — `out/<name>/<NN>/` — which holds its own `timeline.js`,
57
+ `manifest.json`, `scenes/`, `narration/`, `parts/`, `publish.json` and the
58
+ finished MP4. A work dir belongs to exactly one video: the stage tools stamp a
59
+ `.video-identity` marker into it and **refuse** a run whose `videoIndex` disagrees,
60
+ because every video's `narration.wav`, `timeline.json`, `lines.json` and MP4 share
61
+ the same names — a second video in the same dir would silently overwrite the
62
+ first's timings. `--work` / `--out` are resolved against the current directory
63
+ (an absolute path is fine); `render` converts it to a `file://` URL itself.
64
+ - **Videos are independent.** Nothing is shared between them, so two videos can
65
+ coexist and either can be re-rendered or re-assembled on its own (ROADMAP §1,
66
+ *Work-dir layout*). The only shared resource is Chromium's `--port`, so give every
67
+ concurrent `render`/`assemble` its own: `assemble.mjs --port <n>` forwards the port
68
+ to each segment render and **refuses a port already in use**, because sharing one
69
+ does not fail cleanly — the second run attaches to the first browser's page and
70
+ encodes the wrong video under its own filename, with exit 0.
71
+ - **One video end-to-end is a valid stopping point** (a pilot, or a single-target
72
+ run). The series is the full deliverable, but each video is produced
73
+ independently.
74
+
75
+ ## Stage 0 — `/inputs`: source, destination, scratch
76
+
77
+ Ask for what the user must decide; everything else is the skill's. If the invocation
78
+ already names them, skip. Ask **together, one prompt, with defaults** for destination
79
+ and scratch so the user only adjusts, never invents:
80
+
81
+ - **Source** — the notes directory. If they referenced a path (`@…`), use it;
82
+ otherwise list candidate directories that contain `.md` files and let them pick.
83
+ - **Destination** — where the finished video(s) land. Default: `videos/` beside the
84
+ source. Each finished video lands in its **own folder** under the destination —
85
+ `<destination>/<NN>-<slug>/` — holding the `.mp4`, `title.txt`, `description.md`
86
+ and `captions.srt`. The folder name is derived from the plan's `videoIndex` (the
87
+ consumption order) and a slug of the title, so a series sorts into watch order and
88
+ a shared destination can never collide across videos. If the user puts it **inside**
89
+ the source — `<source>/videos/` is a common choice — Stage 1 must `--exclude` it, or
90
+ a published sidecar re-enters the plan as a note (Stage 1 has the detail).
91
+ - **Scratch root** — the directory that holds `out/`, i.e. the cwd for every command
92
+ in Stages 1–8. Default: **`<source>/..`** — beside the source, the same anchor the
93
+ destination uses, which also keeps it inside whatever repo already ignores `out/`.
94
+ Not a throwaway: a series runs to hundreds of MB of WAV and MP4, so it is worth one
95
+ line in the prompt rather than a surprise later. Say plainly that the default is
96
+ fine; the user should not have to think about it.
97
+
98
+ Then `cd` to the scratch root once and stay there. It is the only cwd that makes the
99
+ `out/<name>/…` paths below resolve. If that root is inside a git work tree, `planner`
100
+ verifies `out/` is ignored and appends a rule when it is not, printing what it changed;
101
+ outside a repo there is nothing to ignore and it stays silent.
102
+
103
+ Proceed to Stage 1 only once all three are set. After Stage 1, show the plan's own
104
+ numbers as a free confirmation — *"`<source>` → N markdown files → M videos, landing
105
+ in `<destination>`"* — before Stage 2 and the `Choose the scope` gate spend anything.
106
+
107
+ ## Stage 1 — `/plan`: decompose and prove completeness
108
+
109
+ ```bash
110
+ node tools/planner.mjs <notesDir> --out out/<name> --max-min 25 \
111
+ [--exclude <notesDir>/videos]
112
+ ```
113
+
114
+ Writes `out/<name>/plan.json` and `out/<name>/plan.md`. No network, no key, safe
115
+ to re-run. Read `plan.md`, not `plan.json`.
116
+
117
+ `--exclude` is not optional when the destination sits **inside** the source (the
118
+ common case: `<notesDir>/videos/`). The planner walks everything under `<notesDir>`,
119
+ so a published `description.md` re-enters as a note and becomes a phantom video
120
+ titled after its folder. The planner names this if it sees it — `description.md` is a
121
+ name this pipeline publishes — but passing the exclusion is the actual fix, and it is
122
+ repeatable if several old output folders exist.
123
+
124
+ **Done when:** the run exits **0**, `plan.md`'s coverage line reads
125
+ `N assigned · M excluded as navigation · 0 orphaned`, **and you have shown the user
126
+ every planned video**. A non-zero exit, or any orphan, means the plan is incomplete —
127
+ stop and fix it. Never enrich an incomplete plan.
128
+
129
+ **Show all of them.** The plan's whole point is that it can be argued with row by row,
130
+ so display the complete list — index, title, length — however many there are. Concretely:
131
+ no sample, no "first 10 of 136", no "…and 126 more", no relying on the user to open
132
+ `plan.md`. A 136-video plan is 136 lines and that is the correct amount of output; a
133
+ truncated list silently turns a reviewable proposal into a fait accompli. (`planner`
134
+ now prints the whole plan for exactly this reason — if you post-process it, keep every
135
+ row.)
136
+
137
+ Two facts that shape how you read the output:
138
+
139
+ - **The coverage proof is the point.** The planner's job is not to write titles —
140
+ it proves every content-bearing section is assigned to a video or is a
141
+ navigation heading. `plan.json` also carries `sectionSpans` (each section's
142
+ `src`, `title`, and 1-based `line`/`endLine`); those spans are your raw material
143
+ for stage 2.
144
+ - **The `Min` column is a guardrail, not a duration.** It is a planning estimate
145
+ (`wpm 153 × expansion 1.10`, in `tools/model.mjs`), not measured audio. Use it
146
+ to judge boundaries, not real length.
147
+
148
+ Only `.md` files are parsed; non-markdown content is invisible to the planner.
149
+
150
+ **`--max-min` is part of the plan, not a preference.** The plan records the model that
151
+ produced it (`model.maxVideoMin`) and its video **indices** are keyed by that split, so
152
+ re-running with a different cap gives a different number of videos — on the module-07
153
+ tree, cap 14 → 136 videos, cap 25 → 96. Any `enrichment.json` is keyed by index, so
154
+ `planner` **refuses** (exit 4) to overwrite a `plan.enriched.json` whose model differs,
155
+ and asks for `--force` or a different `--out`. `script-check` warns when the plan it is
156
+ validating was built with a different cap than it is enforcing.
157
+
158
+ ## Stage 2 — `/enrich`: retitle and curate
159
+
160
+ Write `out/<name>/enrichment.json` — one entry per video — then validate and merge:
161
+
162
+ ```bash
163
+ node tools/apply-enrichment.mjs --plan out/<name>/plan.json \
164
+ --enrichment out/<name>/enrichment.json
165
+ ```
166
+
167
+ Writes `plan.enriched.json` and `plan.enriched.md`.
168
+
169
+ **Done when:** the run exits **0** (no validation problems), `plan.enriched.md` shows
170
+ the final video list, **and you have shown the user that complete list** — every
171
+ surviving video, plus the removals you made with their stated reasons. A problem list
172
+ means your enrichment is wrong — fix and re-run.
173
+
174
+ Same rule as stage 1: all rows or it is not a review. `apply-enrichment` prints the
175
+ full table; do not summarise it away.
176
+
177
+ ### The title contract
178
+
179
+ The planner's titles are placeholders. Replace each with a real title:
180
+ descriptive, **unique**, written for a human scrolling YouTube — never a directory
181
+ slug (`load-balancing`). Title from the content, not the name: read the source at
182
+ the video's `sectionSpans` before naming it. The tool rejects empty titles, `Part
183
+ N` placeholders, ellipses, slugs, and duplicates — a slug or duplicate you leave
184
+ in is a hard failure, not a nit.
185
+
186
+ ### Curation — skip and merge
187
+
188
+ The planner cannot tell an overview page from real content, so it plans both. You
189
+ decide. An entry may carry:
190
+
191
+ - `skip: true` — drop the video; its sections are recorded, not silently lost.
192
+ - `mergeInto: <index>` — fold the video's sections and words into another video.
193
+
194
+ Both require a `why`. Every skip/merge lands in `plan.removed` with its reason and
195
+ sections, so coverage stays auditable.
196
+
197
+ ## Choose the scope — after `/enrich`, before `/script`
198
+
199
+ The enriched plan is the decision point. **Ask the user which videos to run through
200
+ stages 3–8 — never assume "all of them".** Present **every** video (all N of them:
201
+ index + title), not a sample or a "top 10", and ask for:
202
+
203
+ - **all** videos, or
204
+ - a **subset by index** — `30`, or `5, 7, 9`, or `1-3, 30`.
205
+
206
+ Then run stages 3–8 only for the chosen set, one video at a time, each into its own
207
+ `out/<name>/<NN>/` (see *Series scope*). TTS and rendering are the expensive stages,
208
+ so never spend them on a video the user has not explicitly chosen.
209
+
210
+ ## Timing — every stage 3–8 is stamped
211
+
212
+ Per-video work starts here. Each stage is bracketed by a `stamp` pair so the series
213
+ accrues a per-video, per-stage audit; the `end` stamp is part of the stage's *Done
214
+ when*, so a stage is not complete until it is timed:
215
+
216
+ ```bash
217
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage <n> [--note …]
218
+ … run the stage …
219
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage <n>
220
+ ```
221
+
222
+ `begin` writes a throwaway marker; `end` subtracts it and appends one row to
223
+ `out/<name>/timing.jsonl` — `{video, stage, attempt, seconds, ended_at, note}` — and a
224
+ retry of the same stage becomes the next `attempt` automatically. Render the audit with:
225
+
226
+ ```bash
227
+ node tools/timing-report.mjs --out out/<name> # → timing.csv · timing.md · timing.svg · timing.html
228
+ ```
229
+
230
+ `timing.html` is the interactive view — self-contained (Plotly and the chart script are
231
+ inlined, so it opens with no network): horizontal stacked bars, hover for exact seconds,
232
+ legend-click to toggle a stage, zoom/pan/download from the modebar. `timing.svg` is the
233
+ no-JS fallback; `timing.csv`/`timing.md` carry the full per-video, per-attempt detail.
234
+
235
+ **Grouping is automatic.** The plan already groups videos by module — the first path
236
+ segment of a video's first source, or its `group` when the plan sets one (a code-flow plan groups
237
+ by flow) — so an 85-video series is really 17 modules. When the
238
+ ledger spans more than one module, the chart shows **one aggregate bar per module** and a
239
+ click breaks that module down by video; a single-module series renders every video
240
+ instead, since grouping it would be a pointless extra click. `--flat` forces the
241
+ per-video chart for a multi-module series.
242
+
243
+ **When to render it** — the ledger is the record and the views are free to regenerate, so
244
+ this is never urgent:
245
+
246
+ - **Series end** — always; this is the audit deliverable.
247
+ - **Module boundary** — during a long run, when the next video's module differs from the
248
+ one just finished. The bar for that module is complete, so it is worth a look.
249
+ - **On demand** — any time you or the user wants it.
250
+
251
+ Do **not** render mid-video (a stage may be stamped, so its totals are partial) or after a
252
+ `--force` re-plan (the ledger keys on the plan index, which reshuffling breaks).
253
+
254
+ The stage lives in the `stage` *value*, never a column name, so stages can be added,
255
+ renamed, or merged later without touching the file's shape.
256
+
257
+ ## Stage 3 — `/script`: write the narration
258
+
259
+ ```bash
260
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 3
261
+ ```
262
+
263
+
264
+ One video at a time, write `out/<name>/scripts/<NN>-<slug>.json`, then validate:
265
+
266
+ ```bash
267
+ node tools/script-check.mjs --script out/<name>/scripts/<NN>-*.json \
268
+ --plan out/<name>/plan.enriched.json
269
+ ```
270
+
271
+ **Done when:** the run exits **0** (`✓ script is valid`). An `UNCOVERED source
272
+ section` error — or any other error — means the script is incomplete; fix and
273
+ re-run.
274
+
275
+ ### The script contract
276
+
277
+ - **Register** — a calm, methodical teacher who explains through story: each
278
+ concept opens as a concrete scenario ("a request arrives…", "one server falls
279
+ over…"), then unpacks step by step. No edge, no hot takes; steady and warm;
280
+ analogies are welcome as teaching devices.
281
+ - **Granularity** — one segment per visual beat (one idea, one reveal). Roughly
282
+ 40–80 words; **≤130 words** hard ("one breath"). Split rather than bloat.
283
+ - **Per segment** — `key` (unique, short), `text` (spoken **verbatim**; no URLs,
284
+ markdown, or code), `style` (delivery direction), `visual` (a hint for the scene
285
+ stage), `covers` (the plan sections this segment narrates), and optionally `pad`
286
+ (hold silence in seconds — see Stage 5).
287
+ - **`covers`** — copy the exact section identity from `plan.enriched.json`
288
+ (`"src :: title @L<line>"`). Every assigned section must appear in some segment's
289
+ `covers`; `script-check` errors on any uncovered section.
290
+ - **Budget** — target `planTargetMinutes` from the plan; stay within its 55%–125%
291
+ band (`script-check` errors below, warns above).
292
+
293
+
294
+ ```bash
295
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 3
296
+ ```
297
+
298
+ ## Stage 4 — `/audio`: TTS and the measured timeline
299
+
300
+ ```bash
301
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 4
302
+ ```
303
+
304
+
305
+ ```bash
306
+ node tools/build-audio.mjs --script out/<name>/scripts/<NN>-*.json --out out/<name>/<NN> --voice Aoede
307
+ ```
308
+
309
+ Writes `narration/<seg>.wav`, `narration.wav`, `timeline.json`, and `timeline.js`
310
+ (browser-loadable) — all into the work dir.
311
+
312
+ A **partial** run (`--only <key>`, a smoke test) writes none of the three global
313
+ artefacts. It stops with a segment-scoped `timeline.<key>.json` and leaves an
314
+ existing `timeline.json` / `narration.wav` untouched, so a smoke test can never
315
+ leave a finished video with a one-scene timeline.
316
+ This is **the only network stage** — it spends the Gemini TTS key. The key is read from
317
+ the OS secret store — macOS Keychain · Linux `secret-tool` · Windows Credential Manager — never env or
318
+ a plaintext file. If it is missing, `build-audio` prints the exact one-time store command
319
+ for the platform; tell the user to run it.
320
+
321
+ **Done when:** the run exits **0** and prints one window per segment tiling the
322
+ timeline, ending in `[out] …/timeline.json`.
323
+
324
+ Two facts:
325
+
326
+ - **Durations are measured, not estimated.** Each WAV is measured with `ffprobe`;
327
+ the plan's `Min` was a guardrail, this is the real number the later stages bind to.
328
+ - **The cache is text-keyed.** `narration/<key>.src` stores the exact spoken text,
329
+ so a re-run after a wording tweak costs 0 calls — but the key does **not** cover
330
+ `--voice`, `style`, or `--model`: change one of those and the old audio is silently
331
+ reused. Pass `--force` to re-synthesize.
332
+
333
+
334
+ ```bash
335
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 4
336
+ ```
337
+
338
+ ## Stage 4b — `/align`: word and line timings
339
+
340
+ ```bash
341
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 4b
342
+ ```
343
+
344
+
345
+ ```bash
346
+ node tools/align.mjs --script out/<name>/scripts/<NN>-*.json --work out/<name>/<NN>
347
+ ```
348
+
349
+ Transcribes the **whole narration track in one request** with word offsets, cuts those
350
+ words into segments at the measured `timeline.json` boundaries, **aligns each segment's
351
+ words back onto that segment's script words**, and writes the span of every narration
352
+ line to `lines.json` (plus `narration/<key>.words.json`, cached per segment).
353
+
354
+ One request per video, not one per segment: `gemini-3.5-transcribe` is capped at **100
355
+ requests/day per project per model** with no Batch API, so per-segment alignment would
356
+ cap the pipeline at ~3 videos/day. A track over ~28 minutes is cut at segment
357
+ boundaries into several passes, since 30 min is the model ceiling with word timings.
358
+
359
+ Why the alignment step is mandatory: the transcriber is not a faithful echo. Measured
360
+ on real narration it returned `In March, it pushes…` (inserted comma), `spends 40
361
+ minutes` (we wrote *forty*), `failed` (we wrote *fail*), and merged two sentences into
362
+ one. So **our punctuation decides the boundaries and the aligned words supply the
363
+ times** — never the transcript's own segmentation.
364
+
365
+ Word offsets occasionally contain outliers (one plan put `A delta` at 3.7s between
366
+ neighbours at 12.4s and 14.2s); they are forced monotonic, and the repairs are
367
+ reported.
368
+
369
+ **Done when:** `align` exits **0** and prints `✓ N segment(s) · M lines · written`.
370
+ Line times are relative to the narration WAV; `lines.json` records `lead` per segment,
371
+ so scene time is `line.start + lead`.
372
+
373
+
374
+ ```bash
375
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 4b
376
+ ```
377
+
378
+ ## Stage 5 — `/scene`: author the visuals
379
+
380
+ ```bash
381
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 5
382
+ ```
383
+
384
+
385
+ Each segment is a **beat**: narration plus the visual that illustrates it. Author the
386
+ visual as a list of **visual beats** — steps, each with a minimum dwell and an
387
+ alignment to the narration — never as raw seconds.
388
+
389
+ ### Three hosts — pick per segment (`out/<name>/<NN>/manifest.json`, `key` → `{host, scene}`)
390
+
391
+ - **`diagram`** (`src/scene-diagram.html`) — the declarative lane, and the default for
392
+ most segments. Write a config of `nodes` / `edges` / `reveal` / `hide` / `draw` /
393
+ `flows` / `banners`; `SceneCore.createScene` renders it. `src/scene-core.js` holds
394
+ the node kinds (`client` / `server` / `hub` / `box`), the `flow` idiom, and the
395
+ overlay vocabulary (`title`, `cards`, `chips`, `finalLine`).
396
+ - **`code`** (`src/scene-code.html`) — a segment that walks code. Constrained and
397
+ declarative; mechanical.
398
+ - **`svg`** (`src/scene-svg.html`) — the open lane, for whatever the declarative lane
399
+ can't express. Write your own SVG with one contract: expose
400
+ `renderAt(t, svg, timeline)` (a pure function of time) on `window.SCENE_CONFIG`.
401
+ `scene-core.js` also exports standalone primitives for this lane, each a pure
402
+ `(t, params) → SVG`: `chart` (pie / bar), `hubSpoke`, `loopFlow`, `timeline`,
403
+ `equation` (a small TeX subset — runs, `^{}` / `_{}`, `\frac`, `\sqrt`, Greek and
404
+ relation macros), `sequence` (participants with lifelines, calls revealed in order;
405
+ a queue, HTTP or spawn crossing dashed). Compose them where a beat matches one; drop to raw SVG where it
406
+ doesn't. **Primitives are a floor, not a ceiling.**
407
+
408
+ ### Two chrome defaults worth knowing
409
+
410
+ - **No in-frame progress bar.** `overlays.progress` is opt-in (`scene-core.js` draws one
411
+ only when it is `true`). The finished MP4 plays in a player that already reports
412
+ progress, so a bar duplicates it — and because the bar is the only element that changes
413
+ on an otherwise static frame, it defeats static-frame skipping. Measured on a real
414
+ scene: with the bar excluded, **82% of frames are byte-identical to their predecessor**.
415
+ - **The stage badge is on by default.** It is a position marker, not a title; several
416
+ segments legitimately share one. Do not reuse badge text as chapter titles (stage 6) —
417
+ it is typically uppercased and sometimes absent.
418
+
419
+ ### The beat anchors to a line, never to a second
420
+
421
+ A beat declares **which narration line it illustrates**, not a time:
422
+
423
+ ```js
424
+ window.SCENE_BEATS = { beats: [
425
+ { id: 'march', line: 1 }, // the line this reveal belongs to
426
+ { id: 'april', line: 2 },
427
+ { id: 'caption', lines: [3, 4], hold: true }, // holds across those two lines
428
+ ]};
429
+ ```
430
+
431
+ `line` is a 0-based index into that segment's lines in `lines.json` (stage 4b).
432
+ `lines: [from, to]` claims a range — a closing caption legitimately holds while the
433
+ last two or three sentences play — and `hold: true` lets the final beat run to the cut.
434
+
435
+ **Why not `at`.** A hand-written second cannot be validated, so it is never caught.
436
+ The module-07 pilot shipped `at: 6.6` for a phrase that begins at **12.6s**; every
437
+ check passed because none of them could see the narration. `resolve-beats` therefore
438
+ **rejects** a beat carrying `at` without `line`, and rejects a baked `at` that
439
+ disagrees with the measured line onset by more than 60 ms.
440
+
441
+ ```bash
442
+ node tools/resolve-beats.mjs --work out/<name>/<NN> [--print]
443
+ ```
444
+
445
+ It resolves `line` → `at = line.start + lead`, writes `beats.json`, and fails when a
446
+ line does not exist, beats run out of order, or **a narration line has no beat** —
447
+ which is narration playing with nothing changing on screen.
448
+
449
+ - **Dead air** is *undeclared* silence with nothing changing — that is still a bug.
450
+
451
+ `pacing-check` reads `beats.json` and resolves the beats against the measured
452
+ narration, reporting shortfalls (`beat "legend" needs 8.0s, has 5.0s (+3.0s)`). A
453
+ shortfall is a prompt to hold / pad / extend, **not** a failure — only a beat that
454
+ cannot fit inside its segment at all is an error. A beat that still carries a
455
+ hand-written `at` is reported as a defect, never silently honoured.
456
+
457
+ A beat's dwell should default to **how long its lines are spoken**
458
+ (`lines[to].end − lines[from].start`). That makes a shortfall arithmetically
459
+ impossible: `at + min` lands inside the window by construction. A line-anchored scene
460
+ should therefore report **no** shortfalls at all — the module-07 pilot ran 123 beats
461
+ across 30 scenes with zero.
462
+
463
+ **A shortfall now means you over-asked, not that the audio ran short.** You declared a
464
+ `min` longer than the lines the beat covers. Resolve it in this order:
465
+
466
+ 1. **Widen the range** — `lines: [from, to]`, so the beat holds across the following
467
+ line too. Free: no audio re-tile, no re-alignment.
468
+ 2. **Redesign the beat** — a visual needing longer than its words is usually the wrong
469
+ visual for them. Four one-word lines ("Staleness." … "Recall failure.") cannot carry
470
+ four sequential card reveals; show all four at once and highlight each as it is
471
+ named.
472
+ 3. **`pad` the segment** — last resort. It buys screen time with silence: costs a
473
+ re-tile, lengthens the video, adds no content.
474
+
475
+ Padding used to be step one because a guessed beat could not be trusted to fit inside
476
+ its window. Beat times are measured now, so padding is a design decision you should
477
+ have to argue for, not a routine repair.
478
+
479
+ **If you do reach step 3** — the `/scene ↔ /audio` loop. Set `pad` on the script
480
+ segment and `hold: true` on the beat, then re-run `/audio` (text-cached, so free; it
481
+ re-tiles the window wider). A `pad` widens the **window** only, never
482
+ `narration/<key>.wav`, so `lines.json` still describes the audio that plays and the
483
+ resolved beat times stay correct.
484
+
485
+ **Rewriting the narration is not free the same way.** Changing a segment's `text`
486
+ re-synthesises its WAV and invalidates every line time measured for it:
487
+
488
+ ```
489
+ pad → /audio → (line data still valid)
490
+ text → /audio → /align → /resolve-beats
491
+ ```
492
+
493
+ `align.mjs` stamps each segment's text hash into `lines.json`, and
494
+ `resolve-beats --script …` **fails** when the script no longer matches, naming the
495
+ segment (the word cache makes re-aligning free unless the audio changed).
496
+
497
+ ### Design rules
498
+
499
+ - **Reveal dwell is content-weighted** — a reveal stays up long enough to read its
500
+ words, not a fixed beat.
501
+ - **Note windows come from reading time, not leftover space.**
502
+ - **Connectors retire with their source node** — fade a connector before its source
503
+ hides, or it dangles.
504
+
505
+ ### Validate timing
506
+
507
+ ```bash
508
+ node tools/pacing-check.mjs --scene out/<name>/scenes/<name>.js \
509
+ --timeline out/<name>/timeline.json --segment <key>
510
+ ```
511
+
512
+ Asserts: the scene fits the **measured** narration · every silent stretch is either a
513
+ declared hold or an error.
514
+
515
+ ### The authoring loop, and the visual check
516
+
517
+ ```
518
+ write/edit a scene
519
+ → pacing-check (timing, shortfalls, dead air)
520
+ → render --shots 6,20,34 --work out/<name>/<NN> --scene "src/scene-diagram.html?scene=<name>"
521
+ → LOOK at the stills ← the step nothing automates
522
+ → fix → repeat
523
+ ```
524
+
525
+ The checkers catch timing and structure, never a visual fault — a missing chip, an
526
+ orphaned connector, a wrong colour, an absent fail marker. Only looking at a
527
+ rendered frame catches those.
528
+
529
+ **Vision gate:** `read_image` the first still. If it errors, you are on a text-only
530
+ model — ask the user whether to switch to a vision model and re-run, or to skip the
531
+ visual check. Either answer is fine; never "look" blind.
532
+
533
+ **Done when:** `pacing-check` exits 0 **and** every still has been eyeballed — or the
534
+ user skipped the visual check.
535
+
536
+
537
+ ```bash
538
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 5
539
+ ```
540
+
541
+ ## Stage 6 — `/publish`: chapters and captions
542
+
543
+ ```bash
544
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 6
545
+ ```
546
+
547
+
548
+ ```bash
549
+ node tools/publish.mjs --work out/<name>/<NN> \
550
+ --script out/<name>/scripts/<NN>-*.json \
551
+ --dest <destination>
552
+ ```
553
+
554
+ Both artifacts are **derived** from data already final when stage 5 exits: the
555
+ measured windows in `timeline.json`, and the verbatim narration. It costs no TTS and
556
+ no Chromium, so it is free to re-run. It sits here — before the expensive render — so
557
+ the metadata can be reviewed while a fix is still cheap.
558
+
559
+ ### Chapters — you author them, the tool resolves the times
560
+
561
+ You write `out/<name>/<NN>/publish.json`:
562
+
563
+ ```json
564
+ {
565
+ "youtubeTitle": "The Harness Is Not the Prompt",
566
+ "chapters": [
567
+ { "segments": ["framing"], "title": "What Is a Harness?" },
568
+ { "segments": ["demo"], "title": "The Demo That Worked" }
569
+ ],
570
+ "blurb": "Two or three sentences on what the video covers."
571
+ }
572
+ ```
573
+
574
+ `youtubeTitle` is the line YouTube shows, and the one a viewer reads before deciding.
575
+ Write it **here**, not at stage 2: only now does the video exist to be described
576
+ honestly. It must be a subject someone would actually search, it must not promise what
577
+ the video does not deliver, and it must differ from every other video in the series — a
578
+ series whose parts are titled alike buries itself.
579
+
580
+ A chapter is a **list of segment keys** plus a short title. One per segment by
581
+ default; grouping is just two keys in one list. A chapter's timestamp is the
582
+ **first** segment's window start, so grouping needs no new timing logic.
583
+
584
+ **Write each title from that chapter's narration** (`narration/<key>.src`) — read it,
585
+ then name it. Do **not** reuse the scene `stageNames` badges: they are *presentation*
586
+ labels and carry defects as chapter titles — all uppercase (`THE MAP`), one missing
587
+ entirely (the title card sets `stageBadge: false`), and near-duplicate pairs 14s apart
588
+ (`SIX FAMILIES` / `THE SIX FAMILIES`). They also resist mechanical title-casing: one
589
+ video contains `IT · TOOLS`, which would become `It · Tools`.
590
+
591
+ ### Captions — verbatim, from the audio
592
+
593
+ One cue per segment, text taken from `narration/<key>.src` — the exact text the WAV
594
+ was built from. Prefer it over `script.json`: if anyone edits the script without
595
+ re-running audio, `.src` is the one that matches what a viewer hears.
596
+
597
+ Emitted as `.srt`. YouTube accepts both SRT and VTT, and SRT is the more widely
598
+ accepted of the two, so one file covers the upload; VTT earns its place only for an
599
+ HTML5 `<track>` in a web embed. Deferred rather than pre-built — re-running this stage
600
+ is free, so it can be added when a web target actually exists. (If it ever is: the
601
+ millisecond separator differs, SRT `,` vs VTT `.`, and a mismatch parses silently as
602
+ nothing.)
603
+
604
+ Segment-level cues are exact but coarse: 13–28s and 32–67 words each. That is
605
+ readable — it implies the speaking rate, ~153 wpm — but it renders as a multi-line
606
+ block. Splitting finer needs word timings the TTS does not return (it sends audio
607
+ only), so it would have to be estimated. Deliberately not done here.
608
+
609
+ ### What the tool asserts
610
+
611
+ YouTube **silently ignores** a chapter list that breaks its rules, so they are hard
612
+ checks: the first timestamp is `0:00` · at least three chapters · every chapter ≥10s ·
613
+ timecodes ascend · every referenced segment key exists · titles present, ≤40 chars,
614
+ and neither duplicated nor near-duplicated. Cues must ascend, not overlap, and number
615
+ one per segment.
616
+
617
+ The **blurb is the one thing not checked** — its shape is validated, its prose is not.
618
+
619
+ ### Chapters need channel eligibility, not just a valid list
620
+
621
+ Chapters are one of YouTube's
622
+ [advanced features](https://support.google.com/youtube/answer/9884579): a channel that has
623
+ not qualified for advanced-feature eligibility renders **no** chapters at all, however
624
+ valid the list. Its description timestamps still work as jump-to-section links — what is
625
+ missing is the segmented progress bar and Google key moments.
626
+
627
+ Nothing in `description.md` affects this, so absent chapters are **not** a formatting
628
+ fault and rewriting a passing list will not help. `0:00` (one digit), a `-` after the
629
+ timecode, a blurb above the block and a 2:39 runtime were each verified live on eligible
630
+ channels, and the hard checks above already mirror YouTube's rules.
631
+
632
+ Outputs, copied into the video's own folder — `--dest/<NN>-<slug>/` — alongside the
633
+ MP4 because they are deliverables rather than scratch — three files:
634
+
635
+ - **`description.md`** — the blurb, then the chapter block, **paste-ready as plain
636
+ text**. YouTube reads chapters only from the description, so the block lives here and
637
+ nowhere else: there is deliberately no separate chapters file. Keep it free of
638
+ markdown syntax (`#`, `**`, backticks) — the description field does not render
639
+ markdown, so those characters would paste in literally.
640
+ - **`captions.srt`** — the cue list, uploaded as a caption track.
641
+ - **`title.txt`** — one line: the `youtubeTitle`. This is the file `upload_yt_video`
642
+ reads out of the folder, so a video folder without it is an incomplete upload.
643
+
644
+ **Done when:** `publish` exits 0 and those three files land in `--dest/<NN>-<slug>/`
645
+ next to the MP4. The folder name is derived by the tool from the script's
646
+ `videoIndex` + title — do not hand-pick a different folder name, or a series will
647
+ sprawl out of watch order.
648
+
649
+
650
+ ```bash
651
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 6
652
+ ```
653
+
654
+ ## Stage 7 — `/render`: frames to MP4
655
+
656
+ ```bash
657
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 7
658
+ ```
659
+
660
+
661
+ ```bash
662
+ node tools/render.mjs --scene "src/scene-diagram.html?scene=<name>" \
663
+ --range <t0>,<t1> --outdir out/<name>/parts --name part-<key>
664
+ ```
665
+
666
+ Renders **one scene config over one window** — headless Chromium via CDP → PNG
667
+ sequence → ffmpeg H.264. It is not a whole-video renderer: `--scene` takes a single
668
+ `host?scene=<config>`, so a multi-scene video is rendered window by window, which
669
+ stage 8 drives. A window rendered with no `--narration` comes out silent, which is
670
+ what assembly wants.
671
+
672
+ **Each distinct frame is captured once.** `renderAt(t)` is a pure function of time, so
673
+ consecutive frames are very often byte-identical — measured on this pipeline: 82% on a
674
+ declarative diagram scene, 90% on an `svg` scene, 99.7% on a held card. A screenshot is
675
+ ~63 ms, the bulk of render wall time, so the capture loop asks the *page* instead: after
676
+ each `renderAt(t)` it hashes the serialized `#stage` SVG (~4 ms) and, when the markup is
677
+ unchanged, hardlinks the previous PNG to this frame number — same inode, zero extra
678
+ bytes — leaving the ffmpeg step untouched. There is **no flag**; this is the only capture
679
+ path. Two guards make a silently-wrong result impossible: a scene that declares CSS/web
680
+ animations (`document.getAnimations()`) takes the plain capture path from frame 0, and
681
+ every 30 frames a real screenshot is diffed against the frame that was written — on
682
+ disagreement the run writes the truthful frame, re-captures every frame since the last
683
+ audit, and drops to plain capture for the rest of the segment. Measured across two full
684
+ videos (72 scenes, ~54,000 frames): SSIM 1.000000 against renders that photographed every
685
+ frame, at 3.3× and 4.3× the speed. The final `[dedup]` line reports screenshots taken vs
686
+ total, reuse %, and audit results; a `dedup off from frame N` line means a guard fired and
687
+ is informational, not an error.
688
+
689
+ If the run fails with `Playwright cache not found`, the headless Chromium is not
690
+ installed — point the user at `SETUP.md` (*Headless Chromium*) rather than touching the
691
+ scene.
692
+
693
+ **Done when:** the run exits **0** and prints `[done] …<name>.mp4`.
694
+
695
+ ### The review loop, and the visual check
696
+
697
+ ```
698
+ render a window
699
+ → --keep-frames (keep the PNG sequence)
700
+ → LOOK at the frames ← the step nothing automates
701
+ → fix out/<name>/scenes/<name>.js → re-render
702
+ ```
703
+
704
+ Stage 5 judged a handful of sampled stills. This looks at every frame of the window,
705
+ so it catches what falls *between* those samples — a chip that flickers, a connector
706
+ that dangles mid-transition, a beat that pops.
707
+
708
+ **Vision gate:** `read_image` the first frame. If it errors, you are on a text-only
709
+ model — ask the user whether to switch to a vision model and re-run, or to skip the
710
+ visual check. Either answer is fine; never "look" blind.
711
+
712
+ **Done when:** the render exits 0 **and** the window's frames have been eyeballed —
713
+ or the user skipped the visual check.
714
+
715
+
716
+ ```bash
717
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 7
718
+ ```
719
+
720
+ ## Stage 8 — `/assemble`: the finished video
721
+
722
+ ```bash
723
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage 8
724
+ ```
725
+
726
+
727
+ ```bash
728
+ node tools/assemble.mjs --script out/<name>/scripts/<NN>-*.json \
729
+ --work out/<name>/<NN> --name <output> --dest <destination> \
730
+ [--port 9333]
731
+ ```
732
+
733
+ `--port` (default 9333) is forwarded to every per-segment `render.mjs`, so **two
734
+ videos can assemble concurrently** when each is given its own port — the batch
735
+ caveat from `SERIES.md` applies to stage 8 too. The run aborts before segment one
736
+ if the port is already listening, because a shared port is the one failure this
737
+ tool cannot detect afterwards: the second assembly silently encodes the first
738
+ video's scene (verified — byte-identical frames, exit 0, a valid MP4 of the wrong
739
+ video). This is why the per-video agent can own stage 8 end to end on its own port.
740
+
741
+ With `--dest`, the finished MP4 lands in `<destination>/<NN>-<slug>/<output>.mp4` —
742
+ the same folder `publish` (stage 6) wrote `description.md` and `captions.srt` into.
743
+
744
+ Per segment: render its own window through its own host + config, **silent** — parts
745
+ are silent on purpose, because muxing narration onto each one would restart the whole
746
+ track at every boundary. Then concatenate (`-c copy`), mux the single measured
747
+ narration track **once**, and verify. `--only <key>` renders a single segment, for a
748
+ smoke test before the full pass.
749
+
750
+ Asserts: every segment has a timeline window **and** a `manifest.json` entry ·
751
+ the windows tile the timeline with no gaps · the finished audio matches the narration
752
+ at **sample level**.
753
+
754
+ **Done when:** the run exits **0** and prints the final `[done] …<output>.mp4` with
755
+ `audio ok`. A pre-flight failure means an upstream artifact is wrong — a missing
756
+ window (stage 4) or a missing manifest entry (stage 5) — not the assembly.
757
+
758
+ ### The review loop, and the visual check
759
+
760
+ ```
761
+ assemble
762
+ → sample the finished MP4 (ffmpeg -i <output>.mp4 -vf fps=1 frames/%04d.png)
763
+ → LOOK at the frames ← the step nothing automates
764
+ → fix out/<name>/scenes/<name>.js → re-assemble
765
+ ```
766
+
767
+ This is the first artifact that exists as a whole video, so it is the first place a
768
+ fault can show that no per-window check can see: a bad seam between two windows, a
769
+ mux that drifts, a beat that breaks only at the join.
770
+
771
+ **Vision gate:** `read_image` the first frame. If it errors, you are on a text-only
772
+ model — ask the user whether to switch to a vision model and re-run, or to skip the
773
+ visual check. Either answer is fine; never "look" blind.
774
+
775
+ **Done when:** the run exits 0 with `audio ok` **and** the assembled frames have been
776
+ eyeballed — or the user skipped the visual check.
777
+
778
+
779
+ ```bash
780
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage 8
781
+ ```
782
+
783
+ ## Reference — enrichment.json
784
+
785
+ ```json
786
+ {
787
+ "seriesNote": "optional one-line framing for the whole series",
788
+ "videos": [
789
+ { "index": 1, "title": "The 5 Databases That Matter in a System Design Interview", "form": "concept", "why": "prose comparison of access patterns" },
790
+ { "index": 33, "skip": true, "why": "phase index — content lives in #34/#35" },
791
+ { "index": 33, "mergeInto": 35, "why": "fold the 'why this phase' prose into #35" }
792
+ ]
793
+ }
794
+ ```
795
+
796
+ `form` ∈ `concept`, `diagram`, `code`, `refactor`, `catalogue`, unless the plan lists its own
797
+ `forms` (a code-flow plan does: `overview`, `flow`, `core`). `skip` and
798
+ `mergeInto` are mutually exclusive; `mergeInto` must target a live video (no
799
+ chains). A missing `why` on either is a validation error.
800
+
801
+ ## Reference — script.json
802
+
803
+ ```json
804
+ {
805
+ "videoIndex": 30,
806
+ "title": "L4 vs L7 Load Balancing and the Algorithms Behind It",
807
+ "form": "concept",
808
+ "planTargetMinutes": 7.9,
809
+ "sources": ["scalability-building-blocks/load-balancing/README.md"],
810
+ "artefacts": ["scalability-building-blocks/load-balancing/code/l4-vs-l7/main.go"],
811
+ "segments": [
812
+ {
813
+ "key": "hook",
814
+ "text": "Every scale-out system hits the same moment eventually…",
815
+ "style": "calm and inviting",
816
+ "visual": "title card, then a single server that fills up",
817
+ "covers": ["scalability-building-blocks/load-balancing/README.md :: 1. Why Load Balance @L9"]
818
+ }
819
+ ]
820
+ }
821
+ ```
822
+
823
+ `videoIndex`, `title`, `form`, `planTargetMinutes`, `sources`, `artefacts` are
824
+ copied from the enriched plan's video; only `text`, `style`, `visual`, `covers` are
825
+ authored — plus optional `pad` (hold silence) and `hold` is set on beats, not
826
+ segments. `covers` must match `plan.enriched.json`'s `sections` strings
827
+ exactly (the `@L<line>` form).
828
+
829
+ ## Reference — publish.json
830
+
831
+ Written per video at `out/<name>/<NN>/publish.json`, then resolved and checked by
832
+ `publish` (stage 6).
833
+
834
+ ```json
835
+ {
836
+ "youtubeTitle": "The Harness Is Not the Prompt",
837
+ "chapters": [
838
+ { "segments": ["framing"], "title": "What Is a Harness?" },
839
+ { "segments": ["demo"], "title": "The Demo That Worked" },
840
+ { "segments": ["smell"], "title": "The Question No Demo Prepares For" }
841
+ ],
842
+ "blurb": "Module one of a harness-engineering course: what a harness is, the six failure families, and how value and risk sit in different layers of three real products."
843
+ }
844
+ ```
845
+
846
+ `youtubeTitle` is the upload title written to `title.txt`, and the only field a viewer
847
+ sees before deciding whether to click. ≤100 chars is a hard error (YouTube's limit);
848
+ past ~70 warns (mobile truncation); all-caps and stacked punctuation warn; a
849
+ near-duplicate of a sibling video's title warns. Omitted, it falls back to the script
850
+ title with a warning — the fallback exists so the folder is never upload-incomplete, not
851
+ because the script title is a good marketing title. It is deliberately **not** the
852
+ `title` used for `<NN>-<slug>`: that one is structural and must not move.
853
+
854
+ `segments` holds one or more segment `key`s from the script, in playlist order; the
855
+ chapter's timestamp is the **first** one's window start. Grouping is expressed by
856
+ listing several keys — there is no separate group field. `title` is short (≤40 chars)
857
+ and must not duplicate or near-duplicate another chapter's. `blurb` is prose and is
858
+ the only field with no mechanical check.