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.
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +154 -2
- package/cordis.patch.yml +7 -0
- package/lib/index.js +35 -0
- package/package.json +56 -4
- package/plugin.json +13 -0
- package/skills/coursework-author/.synced-from +5 -0
- package/skills/coursework-author/SKILL.md +209 -0
- package/skills/craft-codeflow-flows/SKILL.md +116 -0
- package/skills/craft-codeflow-mine/SKILL.md +207 -0
- package/skills/craft-codeflow-plan/SKILL.md +65 -0
- package/skills/craft-codeflow-scene/SKILL.md +168 -0
- package/skills/craft-video-align/SKILL.md +109 -0
- package/skills/craft-video-assemble/SKILL.md +70 -0
- package/skills/craft-video-audio/SKILL.md +59 -0
- package/skills/craft-video-enrich/SKILL.md +46 -0
- package/skills/craft-video-plan/SKILL.md +57 -0
- package/skills/craft-video-publish/SKILL.md +79 -0
- package/skills/craft-video-render/SKILL.md +68 -0
- package/skills/craft-video-scene/SKILL.md +112 -0
- package/skills/craft-video-script/SKILL.md +80 -0
- package/skills/craft-video-thumbnail/SKILL.md +168 -0
- package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
- package/skills/explainer-video-from-codebase/SKILL.md +698 -0
- package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
- package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
- package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
- package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
- package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
- package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
- package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
- package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
- package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
- package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
- package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
- package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
- package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
- package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
- package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
- package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
- package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
- package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
- package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
- package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
- package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
- package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
- package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
- package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
- package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
- package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
- package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
- package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
- package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
- package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
- package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
- package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
- package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
- package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
- package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
- package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
- package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
- package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
- package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
- package/skills/explainer-video-from-coursework/SERIES.md +243 -0
- package/skills/explainer-video-from-coursework/SETUP.md +105 -0
- package/skills/explainer-video-from-coursework/SKILL.md +998 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
- package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
- package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
- package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
- package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
- package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
- package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
- package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
- package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
- package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
- package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
- package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
- package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
- package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
- package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
- package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
- package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
- package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
- package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
- package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
- package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
- package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
- package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
- package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
- package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
- package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
- package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
- package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
- package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
- package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
- package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
- package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
- package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
- package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
- 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.
|