explainer-video 0.0.0-stage → 0.4.1

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