explainer-video 0.4.0 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explainer-video",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Turn structured coursework notes, or a codebase's major code flows, into a narrated, animated explainer-video series.",
5
5
  "author": {
6
6
  "name": "Akshay Prabhakant"
package/README.md CHANGED
@@ -15,7 +15,8 @@ content-bearing section is assigned to a video or classified as navigation
15
15
  ## The pipeline
16
16
 
17
17
  Stage 0 (`/inputs`) collects the two things only you can decide — the source
18
- notes directory, and where the finished videos land.
18
+ notes directory, and where the finished videos land — plus, optionally, the playlist
19
+ name a series is published under (it is never guessed; a series left blank is asked about at 6b).
19
20
 
20
21
  | # | stage | what it does | cost |
21
22
  |---|-------|--------------|------|
@@ -25,6 +26,7 @@ notes directory, and where the finished videos land.
25
26
  | 4 | `/audio` | TTS → a measured timeline | **network — spends the Gemini key** |
26
27
  | 5 | `/scene` | author the visuals (`diagram` / `code` / `svg`) | free, agent |
27
28
  | 6 | `/publish` | YouTube chapters + captions | free — no TTS, no Chromium |
29
+ | 6b | `/thumbnail` | a 1280x720 `thumbnail.png`: one hook over a still from the video's own scenes, plus a `<playlist>-Part X, <title>` label for a series | free — one Chromium screenshot |
28
30
  | 7 | `/render` | frames → H.264 MP4 | local CPU |
29
31
  | 8 | `/assemble` | per-segment parts → the finished cut | local CPU |
30
32
 
@@ -69,7 +71,7 @@ agy plugin install https://github.com/akshayDev17/ai-explainer-video
69
71
  ```
70
72
 
71
73
  All four commands install the **same** npm package — one package, three manifest surfaces,
72
- one copy of all sixteen skills: the ten video skills, `coursework-author`, the companion
74
+ one copy of all seventeen skills: the eleven video skills, `coursework-author`, the companion
73
75
  skill for writing the coursework these videos come from, and the five code-flow skills:
74
76
  `explainer-video-from-codebase`, which makes explainer videos of a codebase's major code flows
75
77
  (Python, TypeScript / JavaScript, Go, C, C++, or a mix), and its four stage skills. It mines the
@@ -101,9 +103,9 @@ Invoke the skill with the notes directory:
101
103
  /explainer-video-from-coursework path/to/your/notes/
102
104
  ```
103
105
 
104
- You get one `.mp4` per video, plus `description.md` (blurb and a paste-ready
105
- chapter block) and `captions.srt`, copied to the destination you chose — by
106
- default `videos/` beside the source. Scratch stays in a gitignored
106
+ You get one `.mp4` per video, plus `title.txt`, `description.md` (blurb and a
107
+ paste-ready chapter block), `captions.srt` and a `thumbnail.png`, copied to the
108
+ destination you chose — by default `videos/` beside the source. Scratch stays in a gitignored
107
109
  `out/<name>/<NN>/`.
108
110
 
109
111
  For a codebase, invoke the code-flow skill with the repo root instead:
@@ -127,7 +129,7 @@ plugin.json Agent Plugins manifest (Codex, Antigrav
127
129
  .agents/plugins/ marketplace.json (Codex)
128
130
  cordis.patch.yml + lib/index.js DSH bundle plugin (serves this package's skills/)
129
131
  skills/explainer-video-from-coursework/ the main skill: SKILL.md, tools/, src/, scenes/, SETUP.md
130
- skills/craft-video-<stage>/ the 9 stage skills, separately invocable
132
+ skills/craft-video-<stage>/ the 10 stage skills, separately invocable
131
133
  skills/coursework-author/ the companion skill: authoring the coursework (vendored copy)
132
134
  skills/explainer-video-from-codebase/ the code-flow skill: SKILL.md, FLOWS.md, tools/, vendored tree-sitter
133
135
  skills/craft-codeflow-<stage>/ its 4 stage skills (mine, flows, plan, scene), separately invocable
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explainer-video",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Turn structured coursework notes, or a codebase's major code flows, into a narrated, animated explainer-video series.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "explainer-video",
4
- "version": "0.4.0",
4
+ "version": "0.4.1",
5
5
  "description": "Turn structured coursework notes, or a codebase's major code flows, into a narrated, animated explainer-video series.",
6
6
  "author": {
7
7
  "name": "Akshay Prabhakant"
@@ -48,7 +48,8 @@ windows tile the timeline with no gaps · the finished audio matches the narrati
48
48
 
49
49
  **Done when:** the run exits **0** and prints `[done] …<output>.mp4` with `audio ok`.
50
50
  The MP4 is copied into the video's own folder, `--dest/<NN>-<slug>/`, alongside the
51
- `description.md` and `captions.srt` that publish wrote.
51
+ `description.md` and `captions.srt` that publish wrote and the `thumbnail.png` that
52
+ thumbnail (stage 6b) wrote.
52
53
 
53
54
  <!-- sync:codeflow-publish-render -->
54
55
  **Code-flow videos only.** A code-flow video's plan, `plan.json` or `plan.enriched.json`, carries
@@ -0,0 +1,168 @@
1
+ ---
2
+ name: craft-video-thumbnail
3
+ description: Design the upload thumbnail for one video — a mildly tacky, attractive 1280x720 PNG with one clear hook over a still from the video's own scenes and, for a series, a "<playlist name>-Part X, <title>" label. Stage 6b of explainer-video-from-coursework.
4
+ ---
5
+
6
+ # craft-video-thumbnail — the upload thumbnail
7
+
8
+ > Part of the `explainer-video-from-coursework` pipeline — **stage 6b of 8**, between
9
+ > publish (6) and render (7). It needs the final upload title (`title.txt`, from stage 6)
10
+ > and the stage-5 scenes, but not the finished MP4, so it runs before the expensive render.
11
+ >
12
+ > The pipeline's tools and scene hosts live in the sibling `explainer-video-from-coursework` skill: run them as `node ../explainer-video-from-coursework/tools/<tool>.mjs`, resolved against this skill's folder. Run from the **scratch root** — the directory holding `out/`, which Stage 0 fixes (default: beside your notes) — so every `out/<name>/…` path in this stage resolves.
13
+
14
+ > **Timing.** Bracket the stage with the stamp pair so the series accrues a per-video audit;
15
+ > `end` must run before the stage counts as done (see *Timing* in the main skill):
16
+ >
17
+ > ```bash
18
+ > node ../explainer-video-from-coursework/tools/stamp.mjs begin --out out/<name> --video <NN> --stage 6b
19
+ > node ../explainer-video-from-coursework/tools/stamp.mjs end --out out/<name> --video <NN> --stage 6b
20
+ > ```
21
+
22
+ **Prerequisites:** `--dest/<NN>-<slug>/title.txt` (stage 6), the video's stage-5 scenes and
23
+ `manifest.json`, `out/<name>/plan.enriched.json`, and the Stage 0 playlist answer; on the
24
+ machine, headless Chromium (see `SETUP.md`) and `ffmpeg` for the phone-size preview.
25
+
26
+ > A `headless Chromium not found` / `Playwright cache not found` failure means Chromium is
27
+ > absent — relay the `SETUP.md` step to the user and re-run.
28
+
29
+ The output is `--dest/<NN>-<slug>/thumbnail.png`, beside `title.txt`: a 1280x720 PNG of at
30
+ most 2,000,000 bytes, exactly what `upload_yt_video` picks up. The upload takes
31
+ `thumbnail.png`, `.jpg` or `.jpeg` from the video's folder, PNG or JPG only, refuses a file
32
+ over 2 MB and ignores `thumbnail.svg`. It is rendered from an HTML template by the
33
+ pipeline's own headless Chromium, never by a paid image-generation API.
34
+
35
+ ### The brief
36
+
37
+ A viewer reads the thumbnail and the title together, in a fraction of a second, usually at
38
+ phone size. Every thumbnail follows the owner's brief:
39
+
40
+ - **Mildly tacky, attractive.** Loud on purpose: fat outlined type, a hard drop shadow, one
41
+ bright accent, a tilted sticker. But one hook and one visual, nothing else competing:
42
+ tacky is the energy, not clutter.
43
+ - **One clear hook.** At most 5 big words (32 characters) that make a viewer want the
44
+ answer: a tension, a surprise, a stake ("The bug nobody catches", "Your data is lying").
45
+ It **complements the upload title and never repeats it**: the title says what the video
46
+ is, the hook says why it is worth the click. Highlight the one word that carries the
47
+ punch. Never promise what the video does not deliver, the same rule as the title.
48
+ - **Sentence case.** Write the hook and the badge in sentence case: the first word capitalised,
49
+ the rest lower case except proper nouns, acronyms and product names ("Your data is lying",
50
+ "Why TypeScript lies"). The template renders text exactly as written, so never type them in
51
+ all capitals.
52
+ - **High contrast.** White type with a black stroke over a darkened wedge, and one accent
53
+ colour (default yellow `#FFD400`) picked to stand out against the still, not to match it.
54
+ - **A strong visual from the video itself.** A still rendered from one of this video's own
55
+ stage-5 scenes, at the beat that shows the idea at its most dramatic (the failure, the
56
+ reveal, the before and after), cropped with `focus` so its subject sits clear of the hook.
57
+ Never stock art, never a logo on its own.
58
+ - **Readable at small mobile size.** If the hook cannot be read in the 320x180 preview, it
59
+ is too long: cut words.
60
+
61
+ ### The series label
62
+
63
+ When the video belongs to a series or an intended playlist, a band across the top reads
64
+ **`<playlist name>-Part X, <title>`**: the first link of chronology a new viewer sees.
65
+
66
+ - **The playlist name comes only from the user**: the Stage 0 answer. Never guess it, never
67
+ default it, never derive it from the notes or the repo. Pass `--standalone`, with no
68
+ label, only when the plan has one video or the user explicitly said there is no
69
+ playlist; a blank Stage 0 answer on a series is not that.
70
+ - **Unset for a series means ask.** With neither `--playlist` nor `--standalone`, a plan of
71
+ more than one video exits **3**. Ask the user whether the series is published as a
72
+ playlist and under what name, then re-run with `--playlist "<name>"` or `--standalone`.
73
+ - **Part X** is computed by the tool: the video's 1-based position in
74
+ `plan.enriched.json`'s `videos` list, its index after curation's skips and merges. It is
75
+ not the raw `videoIndex`, which keeps the gaps curation left.
76
+ - **The title** is the final upload title, read from `title.txt`.
77
+
78
+ ### Pick the still
79
+
80
+ Choose the beat from the scenes you authored at stage 5, then render that instant:
81
+
82
+ ```bash
83
+ node ../explainer-video-from-coursework/tools/render.mjs --shots <t> --work out/<name>/<NN> \
84
+ --scene "src/scene-diagram.html?scene=<name>" --shot-dir out/<name>/<NN>/thumbnail
85
+ ```
86
+
87
+ `--shots` takes seconds of video time; pick one inside the segment's window in
88
+ `timeline.json`. Use the segment's own host (`scene-diagram`, `scene-svg` or `scene-code`).
89
+
90
+ ### Write `thumbnail.json`
91
+
92
+ Write `out/<name>/<NN>/thumbnail.json`:
93
+
94
+ ```json
95
+ {
96
+ "hook": "The bug nobody catches",
97
+ "highlight": "nobody",
98
+ "still": "thumbnail/t000042.png",
99
+ "focus": { "x": 0.7, "y": 0.45, "zoom": 1.3 },
100
+ "accent": "#FFD400",
101
+ "badge": "8 min"
102
+ }
103
+ ```
104
+
105
+ - `hook` (required) — at most 5 words and 32 characters; it must not repeat the upload title.
106
+ - `highlight` (optional) — a word of the hook, or a list of them, drawn in the accent colour.
107
+ - `still` (required) — the PNG from the step above, relative to the work dir.
108
+ - `focus` (optional) — the still's point to keep in view, `x` and `y` as fractions (0–1),
109
+ and a `zoom` of 1–4. The hook sits on the left, so frame the subject to the right.
110
+ - `accent` (optional) — a hex colour for the highlight, the label band, the badge and the bar.
111
+ - `badge` (optional) — a short tilted sticker, 1–16 characters ("8 min", "Fixed"), in sentence case like the hook.
112
+
113
+ ### Render and check
114
+
115
+ ```bash
116
+ node ../explainer-video-from-coursework/tools/thumbnail.mjs --work out/<name>/<NN> \
117
+ --script out/<name>/scripts/<NN>-*.json --plan out/<name>/plan.enriched.json \
118
+ --dest <destination> [--playlist "<name>" | --standalone]
119
+ ```
120
+
121
+ It validates `thumbnail.json`, adds the series label, photographs `src/thumbnail.html` at
122
+ 1280x720, checks the PNG and copies it into `--dest/<NN>-<slug>/`. It also writes
123
+ `out/<name>/<NN>/thumbnail.small.png`, a 320x180 copy for the phone-size look. It costs no
124
+ TTS and takes about a second, so iterate freely.
125
+
126
+ | Exit | Meaning |
127
+ |---|---|
128
+ | 0 | written: `thumbnail.png` in the work dir and in `--dest/<NN>-<slug>/` |
129
+ | 1 | a missing input or prerequisite: no `thumbnail.json`, no `title.txt` (run `/publish` first), a video not in the enriched plan, no Chromium |
130
+ | 2 | a check failed: the hook, the still, the crop, or the PNG (not 1280x720, not a PNG, over 2,000,000 bytes) |
131
+ | 3 | a series with no playlist decision: ask the user, never invent a name |
132
+
133
+ ### Look at it
134
+
135
+ The tool checks the shape of the thumbnail, never its appeal. **Vision gate:** read
136
+ `thumbnail.png` as an image. If that fails, you are on a text-only model: ask the user
137
+ whether to switch to a vision model or to skip the visual check. Never "look" blind.
138
+
139
+ Read both `thumbnail.png` and `thumbnail.small.png`, and check:
140
+
141
+ - the hook reads in the small copy, and the highlighted word is the punch;
142
+ - the hook does not cover the still's subject (move `focus`, or pick another still);
143
+ - the label, when there is one, is whole and names the right part and title;
144
+ - nothing is clipped at an edge, and the accent stands out against the still;
145
+ - it is loud and a little tacky, and still one idea, not a collage.
146
+
147
+ **Done when:** `thumbnail` exits 0, `thumbnail.png` sits in `--dest/<NN>-<slug>/` beside
148
+ `title.txt`, and both PNGs have been looked at, or the user skipped the visual check.
149
+
150
+ <!-- sync:codeflow-thumbnail -->
151
+ **Code-flow videos only.** A code-flow video's plan, `plan.json` or `plan.enriched.json`, carries
152
+ `"kind": "codebase"`, and its `forms` are `overview`, `flow` and `core`. A coursework plan has no `kind`; nothing
153
+ in this block applies to its videos. A code-flow video adds these rules at stage 6b:
154
+
155
+ - **Hook.** Name the flow in the viewer's terms, such as what a request does, what breaks or what a command runs
156
+ ("One click, three services"). Never use a module path, a file name or an identifier: `thumbnail` refuses a hook
157
+ word that is a path (`./x`, `dir/file.ext`, `a/b/c`), lowercase-first camelCase (`getUser`), snake_case, a dotted
158
+ module path (`pkg.mod.fn`), `::` or a call (`fn()`). Viewer words such as TypeScript, macOS, gRPC, I/O and CI/CD pass.
159
+ - **Still.** Take it from a diagram beat (the overview's system map, a flow's sequence, an eval's hand-off), never
160
+ from a code panel: a wall of code is unreadable texture at thumbnail size.
161
+ - **Secrets.** Never put a secret or a redacted value on the thumbnail.
162
+ <!-- /sync:codeflow-thumbnail -->
163
+
164
+ ## Maintenance
165
+
166
+ > If you modify this file, or the user asks to change this stage, tell the user that the
167
+ > main `explainer-video-from-coursework` skill's **"Stage 6b — /thumbnail"** section must be
168
+ > reconciled to match, and name that section in your reply.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: explainer-video-from-codebase
3
- description: Turn the major code flows of a codebase (Python, TypeScript / TSX, JavaScript, Go, C, C++, or a mix such as a Python backend with a TypeScript frontend) into a narrated explainer-video series. Mine the repo into a grouped code map, rank candidate flows as evidence, optionally trace the test suite in a sandbox, propose the series with a reason per flow for the user to approve, plan the videos under a 25-minute cap, then script, voice, animate (verified code panels and generated flow diagrams), publish, render and assemble each one. Use when the user wants explainer videos of how a codebase works.
3
+ description: Turn the major code flows of a codebase (Python, TypeScript / TSX, JavaScript, Go, C, C++, or a mix such as a Python backend with a TypeScript frontend) into a narrated explainer-video series. Mine the repo into a grouped code map, rank candidate flows as evidence, optionally trace the test suite in a sandbox, propose the series with a reason per flow for the user to approve, plan the videos under a 25-minute cap, then script, voice, animate (verified code panels and generated flow diagrams), publish, design the thumbnail, render and assemble each one. Use when the user wants explainer videos of how a codebase works.
4
4
  ---
5
5
 
6
6
  # explainer-video-from-codebase — mine, propose, approve, plan, then the per-video stages
@@ -14,10 +14,10 @@ scratch root. Stages 1–1c run once over the whole repo:
14
14
  - code plans the videos in the coursework plan's shape.
15
15
 
16
16
  Stages 2–8 are `explainer-video-from-coursework`'s own stages, run on that plan: retitle once, then per video
17
- write the narration, synthesize the audio, author the visuals, generate the publishing metadata, render and
18
- assemble. Three places differ for code. Stage 3 adds the code-video rules below, and stages 6–8 add a render and
19
- a title rule. Stage 5 uses `craft-codeflow-scene`: code panels cut from the repo and diagrams generated from the
20
- validated flows.
17
+ write the narration, synthesize the audio, author the visuals, generate the publishing metadata, design the
18
+ thumbnail, render and assemble. Four places differ for code. Stage 3 adds the code-video rules below, stages 6–8
19
+ add a render and a title rule, and stage 6b adds a hook and a still rule. Stage 5 uses `craft-codeflow-scene`: code
20
+ panels cut from the repo and diagrams generated from the validated flows.
21
21
 
22
22
  **Paths.** `<skill>` is this skill's folder and `<coursework>` is the sibling `../explainer-video-from-coursework`,
23
23
  both resolved against this skill's folder. The tools self-locate everything else, so they run regardless of cwd.
@@ -35,8 +35,9 @@ the **scratch root** that Stage 0 fixes. The scratch root is always **outside th
35
35
  >
36
36
  > Each is a complete contract for its stage, top-level under `skills/` so any stage can be re-run alone.
37
37
  > Stages 2–4b and 6–8 are the coursework `craft-video-<stage>` skills. Stages 2, 4 and 4b are unchanged for code;
38
- > `craft-video-script` (stage 3) and `craft-video-publish`, `-render` and `-assemble` (stages 6–8) also carry this
39
- > file's code-video rules for their stage, in a sync block that opens **Code-flow videos only**. Every
38
+ > `craft-video-script` (stage 3), `craft-video-publish`, `-render` and `-assemble` (stages 6–8) and
39
+ > `craft-video-thumbnail` (stage 6b) also carry this file's code-video rules for their stage, in a sync block that
40
+ > opens **Code-flow videos only**. Every
40
41
  > `craft-codeflow-*` skill ends with a **Maintenance** note naming its section here. **If you change a stage, update
41
42
  > both files.**
42
43
  > `scripts/check-skill-sync.mjs` (CI) fails when they drift:
@@ -111,9 +112,9 @@ what is missing and the install steps from the matching `SETUP.md` section, then
111
112
  | 3 | `/script` | per video | `craft-video-script` (its code-flow block, the addendum below) | `script-check` exits 0 (pause rule included) |
112
113
  | 4, 4b | `/audio`, `/align` | per video | `craft-video-audio`, `craft-video-align` | unchanged |
113
114
  | 5 | `/scene` | per video | **`craft-codeflow-scene`** | `excerpt-check` and `pacing-check` exit 0; the stills looked at |
114
- | 6–8 | `/publish`, `/render`, `/assemble` | per video | `craft-video-publish`, `-render`, `-assemble` (their code-flow block) | unchanged |
115
+ | 6, 6b, 7, 8 | `/publish`, `/thumbnail`, `/render`, `/assemble` | per video | `craft-video-publish`, `-thumbnail`, `-render`, `-assemble` (their code-flow block) | unchanged; 6b writes `thumbnail.png`, looked at |
115
116
 
116
- ## Stage 0 — `/inputs`: repo, destination, scratch
117
+ ## Stage 0 — `/inputs`: repo, destination, scratch, playlist
117
118
 
118
119
  Ask for what the user must decide. If the invocation already names them, skip the question. Ask **together, in
119
120
  one prompt, with defaults**, so the user only adjusts and never invents:
@@ -125,6 +126,11 @@ one prompt, with defaults**, so the user only adjusts and never invents:
125
126
  folder beside the repo, `<repo>/../<repo name>-videos/`. It must be outside the repo, since the tools refuse to
126
127
  write there. A series runs to hundreds of MB of WAV and MP4, so it is worth one line in the prompt. Say
127
128
  plainly that the default is fine.
129
+ - **Playlist name** (optional) — the playlist the series is published under, or *none* to say it has no playlist.
130
+ Stage 6b prints it on every thumbnail as `<playlist name>-Part X, <title>`. It has **no default** and is never
131
+ guessed, least of all from the repo's name. Carry the answer to stage 6b as `--playlist "<name>"`; pass
132
+ `--standalone` only when the plan has one video or the user explicitly said there is no playlist. Blank is not
133
+ *none*: on a plan of more than one video it passes neither flag, so stage 6b exits 3 and you ask.
128
134
 
129
135
  Note, without asking unless the user raises them, the optional scope flags stage 1 takes:
130
136
 
@@ -133,7 +139,8 @@ Note, without asking unless the user raises them, the optional scope flags stage
133
139
  - `--group 'glob=prod|test|eval|dev'` for a file the group rules misplace;
134
140
  - `--config-root` for config kept outside the repo, such as a deploy repo's Helm values.
135
141
 
136
- Then change to the scratch root once and stay there. Proceed to stage 1 only once all three are set.
142
+ Then change to the scratch root once and stay there. Proceed to stage 1 only once the repo, destination and scratch
143
+ root are set.
137
144
 
138
145
  ## Stage 1 — `/mine`: code map, candidates, ranking
139
146
 
@@ -623,7 +630,8 @@ node <coursework>/tools/stamp.mjs end --out out/<name> --video <NN> --stage 5
623
630
 
624
631
  ## Stages 6–8 — `/publish`, `/render`, `/assemble`
625
632
 
626
- Run `craft-video-publish`, `craft-video-render` and `craft-video-assemble`, each with its code-flow block:
633
+ Run `craft-video-publish`, then `craft-video-thumbnail` (stage 6b, below), then `craft-video-render` and
634
+ `craft-video-assemble`, each with its code-flow block:
627
635
 
628
636
  <!-- sync:codeflow-publish-render -->
629
637
  **Code-flow videos only.** A code-flow video's plan, `plan.json` or `plan.enriched.json`, carries
@@ -640,6 +648,37 @@ in this block applies to its videos. A code-flow video adds these rules at stage
640
648
  **Done when:** each stage's own gate holds, ending in `assemble`'s `[done] …<output>.mp4` with `audio ok`, and
641
649
  the assembled frames have been looked at.
642
650
 
651
+ ## Stage 6b — `/thumbnail`: the code-flow hook
652
+
653
+ Run `craft-video-thumbnail` after `/publish` and before `/render`: it needs the final upload title (`title.txt`)
654
+ and the stage-5 scenes, not the finished MP4. It writes `<destination>/<NN>-<slug>/thumbnail.png`, a 1280x720 PNG
655
+ of at most 2,000,000 bytes, beside `title.txt`, where `upload_yt_video` picks it up.
656
+
657
+ ```sh
658
+ node <coursework>/tools/thumbnail.mjs --work out/<name>/<NN> --script out/<name>/scripts/<NN>-*.json \
659
+ --plan out/<name>/plan.enriched.json --dest <destination> [--playlist "<name>" | --standalone]
660
+ ```
661
+
662
+ The brief, the series label and the visual check are the coursework stage's. Part X is the video's 1-based
663
+ position in `plan.enriched.json`'s `videos`, and the playlist name is only ever the user's Stage 0 answer: exit 3
664
+ means a series with no answer, so ask. A code-flow video adds:
665
+
666
+ <!-- sync:codeflow-thumbnail -->
667
+ **Code-flow videos only.** A code-flow video's plan, `plan.json` or `plan.enriched.json`, carries
668
+ `"kind": "codebase"`, and its `forms` are `overview`, `flow` and `core`. A coursework plan has no `kind`; nothing
669
+ in this block applies to its videos. A code-flow video adds these rules at stage 6b:
670
+
671
+ - **Hook.** Name the flow in the viewer's terms, such as what a request does, what breaks or what a command runs
672
+ ("One click, three services"). Never use a module path, a file name or an identifier: `thumbnail` refuses a hook
673
+ word that is a path (`./x`, `dir/file.ext`, `a/b/c`), lowercase-first camelCase (`getUser`), snake_case, a dotted
674
+ module path (`pkg.mod.fn`), `::` or a call (`fn()`). Viewer words such as TypeScript, macOS, gRPC, I/O and CI/CD pass.
675
+ - **Still.** Take it from a diagram beat (the overview's system map, a flow's sequence, an eval's hand-off), never
676
+ from a code panel: a wall of code is unreadable texture at thumbnail size.
677
+ - **Secrets.** Never put a secret or a redacted value on the thumbnail.
678
+ <!-- /sync:codeflow-thumbnail -->
679
+
680
+ **Done when:** `thumbnail` exits 0 and `thumbnail.png` and `thumbnail.small.png` have been looked at.
681
+
643
682
  ## Reference
644
683
 
645
684
  Every artifact's fields are in [`FLOWS.md`](./FLOWS.md):
@@ -29,6 +29,8 @@ judgment to a *second* LLM via an API call is strictly worse:
29
29
  | audio — TTS | **code** | **Gemini TTS API** ← the only API call in the pipeline |
30
30
  | publish config — chapter grouping, titles, blurb | **agent** | none |
31
31
  | `publish.mjs` — resolve timecodes, emit chapters/captions, validate | **code** | none |
32
+ | thumbnail config — the hook, the still, the crop | **agent** | none |
33
+ | `thumbnail.mjs` — series label, Chromium screenshot, PNG check | **code** | none |
32
34
  | `render.mjs` — Chromium frames, ffmpeg | **code** | none |
33
35
 
34
36
  Everything except audio runs offline. The planner and the merge/validate step
@@ -222,6 +222,27 @@ however valid the list. Its description timestamps still work as jump-to-section
222
222
  what is missing is the segmented progress bar and Google key moments. Nothing in
223
223
  `description.md` can change this, so absent chapters are not a formatting fault.
224
224
 
225
+ ### 6b · `/thumbnail` — the upload thumbnail
226
+
227
+ | | |
228
+ |---|---|
229
+ | **Agent writes** | `out/<name>/<NN>/thumbnail.json` — the `hook` (≤5 words), a `still` rendered from one of the video's own scenes, an optional `focus` crop, `accent` and `badge` |
230
+ | Tool | `tools/thumbnail.mjs` + the host `src/thumbnail.html` |
231
+ | Out | `thumbnail.png` (1280x720, ≤2,000,000 bytes) — copied to `--dest/<NN>-<slug>/` beside `title.txt`; `thumbnail.small.png` (320x180) in the work dir for the phone-size look |
232
+ | Asserts | the hook is ≤5 words / 32 chars and does not repeat the upload title · a code-flow hook names no code · the still exists · the PNG is a real PNG, exactly 1280x720, within the upload's 2 MB · a series has a playlist decision (exit 3 otherwise) |
233
+ | Network | none |
234
+
235
+ Placed **after** `/publish` because the label carries the final upload title, read from
236
+ `title.txt`, and **before** `/render` because it needs only the stage-5 scenes, not the
237
+ finished MP4. It is one headless-Chromium screenshot of an HTML template, so iterating on
238
+ it is as cheap as stage 6.
239
+
240
+ For a series, a band reads `<playlist name>-Part X, <title>`, the first link of chronology
241
+ a new viewer sees. The playlist name is only ever the user's Stage 0 answer: the tool has
242
+ no default, and a plan of more than one video with neither `--playlist` nor `--standalone`
243
+ exits 3 so the agent asks. Part X is the video's 1-based position in
244
+ `plan.enriched.json`'s `videos`, after curation, never the raw `videoIndex`.
245
+
225
246
  ### 7 · `/render` — frames to MP4
226
247
 
227
248
  | | |
@@ -258,6 +279,7 @@ whole track at every segment boundary — a bug this stage used to have.
258
279
  | `src/scene-diagram.html` | host; selects the config via `?scene=<name>` |
259
280
  | `src/scene-code.js` | code + refactor renderer — code panels, before/after |
260
281
  | `src/scene-code.html` | host; selects the config via `?scene=<name>` |
282
+ | `src/thumbnail.html` | stage 6b's 1280x720 thumbnail template; `tools/thumbnail.mjs` passes its config as `?c=<json>` |
261
283
  | `<work>/timeline.js` | **generated** by stage 4 into the per-video work dir; browser-loadable measured timeline |
262
284
 
263
285
  ---
@@ -282,6 +304,10 @@ node tools/pacing-check.mjs --scene out/lld/06/scenes/<name>.js --timeline out/l
282
304
  # 6 — publish (agent writes publish.json: chapters + blurb)
283
305
  node tools/publish.mjs --work out/lld/06 --script out/lld/scripts/06-*.json --dest <destination>
284
306
 
307
+ # 6b — thumbnail (agent renders a still, writes thumbnail.json: hook + still)
308
+ node tools/thumbnail.mjs --work out/lld/06 --script out/lld/scripts/06-*.json \
309
+ --plan out/lld/plan.enriched.json --dest <destination> --playlist "<name>"
310
+
285
311
  # 7 + 8 — render and assemble
286
312
  node tools/assemble.mjs --script out/lld/scripts/06-*.json --work out/lld/06 --name video6-solid
287
313
  ```
@@ -16,15 +16,15 @@ assemble stages written into `SKILL.md`; video 6 (SOLID) completed end-to-end.
16
16
 
17
17
  ### The skill
18
18
 
19
- `SKILL.md` — all eight stages (plan, enrich, script, audio, scene, publish, render,
20
- assemble), each with its command, its `done when` gate, and the visual-check
21
- protocol (vision gate + the `LOOK` step). Distributed as one plugin of sixteen skills
19
+ `SKILL.md` — every stage (plan, enrich, script, audio, align, scene, publish, thumbnail,
20
+ render, assemble), each with its command, its `done when` gate, and the visual-check
21
+ protocol (vision gate + the `LOOK` step). Distributed as one plugin
22
22
  (`plugin.json` + `skills/`) — see §2.3.
23
23
 
24
24
  ### The stage skills
25
25
 
26
- Eight sibling skills, one per stage, at `skills/craft-video-<stage>/SKILL.md` — top-level
27
- under `skills/`, so every platform that reads the plugin discovers all nine. Each is a
26
+ One sibling skill per stage, at `skills/craft-video-<stage>/SKILL.md` — top-level
27
+ under `skills/`, so every platform that reads the plugin discovers them all. Each is a
28
28
  complete contract for its stage — prerequisites, command, what the agent writes, hard
29
29
  checks, `done when` — and each is separately invocable, so one stage can be re-run alone.
30
30
  They reach the pipeline's tools as `../explainer-video-from-coursework/tools/<tool>.mjs`,
@@ -50,6 +50,8 @@ source notes
50
50
  │ tools/pacing-check.mjs ........ reveal rate + dead air [code]
51
51
  ├─ /publish agent writes publish.json
52
52
  │ tools/publish.mjs ............. chapters + captions [code]
53
+ ├─ /thumbnail agent writes thumbnail.json
54
+ │ tools/thumbnail.mjs ........... 1280x720 PNG + series label [code]
53
55
  ├─ /render tools/render.mjs .............. Chromium frames + ffmpeg [code]
54
56
  └─ /assemble tools/assemble.mjs ............ per-segment render + concat [code]
55
57
  tools/verify-audio.mjs ........ sample-level audio match [code]
@@ -52,6 +52,7 @@ unmeasured — see *Open parameter* at the end.
52
52
  {
53
53
  "plan": "out/<name>/plan.enriched.json",
54
54
  "destination": "videos/",
55
+ "playlist": "Systems 101",
55
56
  "parallel": 2,
56
57
  "portBase": 9333,
57
58
  "videos": [
@@ -73,6 +74,13 @@ It lives in this file rather than being a flag because a resumed session must no
73
74
  different value: `parallel` determines port allocation, and changing it mid-run
74
75
  invalidates every port already claimed.
75
76
 
77
+ `playlist` is the user's playlist name, verbatim, or `null` only when the user explicitly
78
+ said the series has no playlist. A blank Stage 0 answer is neither: before writing this
79
+ file, the orchestrator asks the user the question stage 6b's exit 3 asks (is the series a
80
+ playlist, and under what name). It is recorded here once so that every per-video agent
81
+ prints the same `<playlist>-Part X, <title>` label on its thumbnail (stage 6b) without
82
+ asking again; it is never guessed or filled in by the orchestrator.
83
+
76
84
  Titles are **not** duplicated here — they live in `plan.enriched.json`, which stays the
77
85
  single source of truth for content. Two copies would drift.
78
86
 
@@ -88,20 +96,22 @@ single source of truth for content. Two copies would drift.
88
96
  "4b": { "done": true, "attempts": 1, "ended_at": "…" },
89
97
  "5": { "done": true, "attempts": 1, "ended_at": "…" },
90
98
  "6": { "done": true, "attempts": 1, "ended_at": "…" },
99
+ "6b": { "done": true, "attempts": 1, "ended_at": "…" },
91
100
  "7": { "done": true, "attempts": 1, "ended_at": "…" },
92
101
  "8": { "done": false, "attempts": 1, "last_error": null }
93
102
  },
94
103
  "artefacts": {
95
104
  "script": "…", "lines": "…", "beats": "…", "manifest": "…",
96
105
  "mp4": "videos/<slug>/<name>.mp4",
97
- "description": "videos/<slug>/description.md", "captions": "videos/<slug>/captions.srt"
106
+ "description": "videos/<slug>/description.md", "captions": "videos/<slug>/captions.srt",
107
+ "thumbnail": "videos/<slug>/thumbnail.png"
98
108
  },
99
109
  "render": { "port": 9333, "attempts": 1 },
100
110
  "qa": { "stills_checked": true, "beats_audited": 48, "verdict": "accept" }
101
111
  }
102
112
  ```
103
113
 
104
- `status` ∈ `queued · authoring · audio · aligning · scenes · publishing · rendering ·
114
+ `status` ∈ `queued · authoring · audio · aligning · scenes · publishing · thumbnail · rendering ·
105
115
  qa · done · failed · needs-fresh`.
106
116
 
107
117
  **One writer per file** — that video's agent. Write atomically (temp + `renameSync`).
@@ -146,6 +156,7 @@ Each stage, in order, with its gate:
146
156
  | **5** | Author `scenes/` + `manifest.json` + `intent.md`; `resolve-beats`; `pacing-check` per segment | pacing is sound for every segment |
147
157
  | **5·QA** | **Stills gate** — `render --shots` at 3–5 mid-segment times on the assigned port, then **look at them** | no collision, no missing heading, nothing cropped |
148
158
  | **6** | `publish.json` → `publish` | chapters and cues validate |
159
+ | **6b** | `thumbnail.json` → `thumbnail --playlist "<series.json playlist>"` (or `--standalone` when it is `null`), then **look at it** | exits 0; the full and phone-size PNGs eyeballed |
149
160
  | **7–8** | `assemble --dest videos` | exits 0 with `audio ok` |
150
161
 
151
162
  Then: sample the finished MP4, look, and either accept or fix-and-re-render.
@@ -1,20 +1,20 @@
1
1
  ---
2
2
  name: explainer-video-from-coursework
3
- description: Plan an explainer-video series from a directory of coursework notes — decompose into videos (proving nothing is dropped), retitle and curate, write the narration, synthesize the audio, author the visuals, then render and assemble the finished cut. Use when the user wants to turn structured coursework notes into explainer videos.
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
4
  ---
5
5
 
6
- # explainer-video-from-coursework — plan, enrich, script, audio, scene, publish, render, assemble
6
+ # explainer-video-from-coursework — plan, enrich, script, audio, scene, publish, thumbnail, render, assemble
7
7
 
8
8
  Turn a notes directory into a **video series**. Stage 0 gathers the source and the
9
9
  destination; stages 1–2 run once over the whole directory and plan the series (the
10
10
  planner decomposes and proves completeness; you retitle and curate); stages 3–8 then
11
11
  run **once per video** — write the narration, TTS turns it into measured audio, you
12
- author the visuals, generate the publishing metadata, then render and assemble that
13
- video's finished cut.
12
+ author the visuals, generate the publishing metadata, design the thumbnail, then render
13
+ and assemble that video's finished cut.
14
14
 
15
15
  The skill's own files (`tools/`, `src/`, `scenes/`) live in **this skill's folder** — resolve
16
16
  `tools/<tool>.mjs` against it; the tools then self-locate everything else, so they run
17
- regardless of cwd. The eight `craft-video-<stage>` skills sit beside this one under `skills/`.
17
+ regardless of cwd. The `craft-video-<stage>` skills, one per stage, sit beside this one under `skills/`.
18
18
  But the work and scratch paths (`out/`, `--work`, `--out`, `<notesDir>`) resolve against the
19
19
  **current directory**, so run every command from the **scratch root** — the directory that
20
20
  holds `out/`. Stage 0 fixes it once, defaulting beside the source. Never run from the skill's
@@ -72,7 +72,7 @@ section, then re-run.
72
72
  run). The series is the full deliverable, but each video is produced
73
73
  independently.
74
74
 
75
- ## Stage 0 — `/inputs`: source, destination, scratch
75
+ ## Stage 0 — `/inputs`: source, destination, scratch, playlist
76
76
 
77
77
  Ask for what the user must decide; everything else is the skill's. If the invocation
78
78
  already names them, skip. Ask **together, one prompt, with defaults** for destination
@@ -82,8 +82,8 @@ and scratch so the user only adjusts, never invents:
82
82
  otherwise list candidate directories that contain `.md` files and let them pick.
83
83
  - **Destination** — where the finished video(s) land. Default: `videos/` beside the
84
84
  source. Each finished video lands in its **own folder** under the destination —
85
- `<destination>/<NN>-<slug>/` — holding the `.mp4`, `title.txt`, `description.md`
86
- and `captions.srt`. The folder name is derived from the plan's `videoIndex` (the
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
87
  consumption order) and a slug of the title, so a series sorts into watch order and
88
88
  a shared destination can never collide across videos. If the user puts it **inside**
89
89
  the source — `<source>/videos/` is a common choice — Stage 1 must `--exclude` it, or
@@ -94,13 +94,19 @@ and scratch so the user only adjusts, never invents:
94
94
  Not a throwaway: a series runs to hundreds of MB of WAV and MP4, so it is worth one
95
95
  line in the prompt rather than a surprise later. Say plainly that the default is
96
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.
97
103
 
98
104
  Then `cd` to the scratch root once and stay there. It is the only cwd that makes the
99
105
  `out/<name>/…` paths below resolve. If that root is inside a git work tree, `planner`
100
106
  verifies `out/` is ignored and appends a rule when it is not, printing what it changed;
101
107
  outside a repo there is nothing to ignore and it stays silent.
102
108
 
103
- Proceed to Stage 1 only once all three are set. After Stage 1, show the plan's own
109
+ Proceed to Stage 1 only once source, destination and scratch root are set. After Stage 1, show the plan's own
104
110
  numbers as a free confirmation — *"`<source>` → N markdown files → M videos, landing
105
111
  in `<destination>`"* — before Stage 2 and the `Choose the scope` gate spend anything.
106
112
 
@@ -651,6 +657,139 @@ sprawl out of watch order.
651
657
  node tools/stamp.mjs end --out out/<name> --video <NN> --stage 6
652
658
  ```
653
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
+
654
793
  ## Stage 7 — `/render`: frames to MP4
655
794
 
656
795
  ```bash
@@ -739,7 +878,8 @@ video's scene (verified — byte-identical frames, exit 0, a valid MP4 of the wr
739
878
  video). This is why the per-video agent can own stage 8 end to end on its own port.
740
879
 
741
880
  With `--dest`, the finished MP4 lands in `<destination>/<NN>-<slug>/<output>.mp4` —
742
- the same folder `publish` (stage 6) wrote `description.md` and `captions.srt` into.
881
+ the same folder `publish` (stage 6) wrote `description.md` and `captions.srt` into, and
882
+ `thumbnail` (stage 6b) wrote `thumbnail.png` into.
743
883
 
744
884
  Per segment: render its own window through its own host + config, **silent** — parts
745
885
  are silent on purpose, because muxing narration onto each one would restart the whole
@@ -0,0 +1,119 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Thumbnail host — stage 6b</title>
6
+ <style>
7
+ html, body { margin:0; padding:0; width:1280px; height:720px; overflow:hidden; background:#0b0b12; }
8
+ #frame { position:relative; width:1280px; height:720px; overflow:hidden; }
9
+ /* the still: a frame of the video's own scene, cropped to its strongest part and pushed louder */
10
+ #still { position:absolute; inset:0; width:1280px; height:720px; object-fit:cover;
11
+ filter:saturate(1.35) contrast(1.12) brightness(0.9); }
12
+ /* a dark wedge behind the hook, so white type reads at any size */
13
+ #shade { position:absolute; inset:0;
14
+ background:linear-gradient(100deg, rgba(0,0,0,.86) 0%, rgba(0,0,0,.62) 42%, rgba(0,0,0,0) 74%),
15
+ linear-gradient(0deg, rgba(0,0,0,.55) 0%, rgba(0,0,0,0) 32%); }
16
+ #label { position:absolute; left:0; right:0; top:0; padding:14px 40px 16px; box-sizing:border-box;
17
+ color:#111; font:800 40px/1.12 "Helvetica Neue", Helvetica, Arial, sans-serif;
18
+ letter-spacing:-0.01em; box-shadow:0 6px 0 rgba(0,0,0,.55); }
19
+ #hook { position:absolute; left:52px; width:780px; display:flex; flex-direction:column; justify-content:center;
20
+ transform:rotate(-2.5deg); transform-origin:left center; }
21
+ #hook .t { margin:0; color:#fff; line-height:0.98; letter-spacing:0.01em;
22
+ font-family:Impact, Haettenschweiler, "Arial Narrow Bold", "Arial Black", sans-serif; font-weight:900;
23
+ -webkit-text-stroke:0.085em #000; paint-order:stroke fill; text-shadow:0.06em 0.07em 0 #000;
24
+ overflow-wrap:normal; word-break:keep-all; }
25
+ #badge { position:absolute; right:46px; padding:14px 26px; transform:rotate(7deg);
26
+ color:#111; font:900 44px/1 Impact, Haettenschweiler, "Arial Black", sans-serif;
27
+ border:6px solid #fff; border-radius:18px; box-shadow:8px 9px 0 #000; }
28
+ #bar { position:absolute; left:0; right:0; bottom:0; height:16px; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <!--
33
+ Stage 6b's thumbnail host. tools/thumbnail.mjs passes the resolved config as JSON in `?c=`:
34
+
35
+ { hook, marked[] (word positions in the hook), label|null, still (file URL), focus {x, y, zoom}, accent, badge|null }
36
+
37
+ and photographs the page at 1280x720 with the headless Chromium. Everything is laid out
38
+ synchronously before the load event, so the screenshot never races the layout.
39
+ -->
40
+ <div id="frame">
41
+ <img id="still" alt="">
42
+ <div id="shade"></div>
43
+ <div id="hook"><p class="t"></p></div>
44
+ <div id="bar"></div>
45
+ </div>
46
+ <script>
47
+ var C = JSON.parse(new URLSearchParams(location.search).get('c') || '{}');
48
+ var frame = document.getElementById('frame');
49
+ var accent = C.accent || '#FFD400';
50
+
51
+ var still = document.getElementById('still');
52
+ var f = C.focus || {};
53
+ var zoom = f.zoom || 1;
54
+ still.style.objectPosition = (100 * (f.x == null ? 0.5 : f.x)) + '% ' + (100 * (f.y == null ? 0.5 : f.y)) + '%';
55
+ still.style.transformOrigin = still.style.objectPosition;
56
+ still.style.transform = 'scale(' + zoom + ')';
57
+ if (C.still) still.src = C.still;
58
+
59
+ document.getElementById('bar').style.background = accent;
60
+
61
+ // shrink a box's font until nothing overflows its width and its content fits h; returns the size it settled on.
62
+ // With `text` (an inline child holding the words), the width test is the text's own width against the box's
63
+ // content width, so the box's side padding stays clear; scrollWidth alone cannot see into the right padding.
64
+ function fit(el, max, min, h, text) {
65
+ var lo = min, hi = max;
66
+ var cs = getComputedStyle(el);
67
+ var room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
68
+ while (hi - lo > 1) {
69
+ var mid = (lo + hi) >> 1;
70
+ el.style.fontSize = mid + 'px';
71
+ var wide = text ? text.getBoundingClientRect().width > room : el.scrollWidth > el.clientWidth;
72
+ if (!wide && el.scrollHeight <= h) lo = mid; else hi = mid;
73
+ }
74
+ el.style.fontSize = lo + 'px';
75
+ return lo;
76
+ }
77
+
78
+ var y0 = 40; // where the hook may start: below the label when there is one (never `top`, which is window.top)
79
+ if (C.label) {
80
+ var label = document.createElement('div');
81
+ label.id = 'label';
82
+ label.style.background = accent;
83
+ var words = document.createElement('span');
84
+ words.textContent = C.label;
85
+ label.appendChild(words);
86
+ frame.appendChild(label);
87
+ // one line when it fits at a readable size, else two
88
+ label.style.whiteSpace = 'nowrap';
89
+ if (fit(label, 40, 18, 200, words) < 30) { label.style.whiteSpace = "normal"; fit(label, 36, 18, 112, words); }
90
+ y0 = label.offsetHeight + 34;
91
+ }
92
+
93
+ if (C.badge) {
94
+ var badge = document.createElement('div');
95
+ badge.id = 'badge';
96
+ badge.style.background = accent;
97
+ badge.style.top = (y0 + 6) + 'px';
98
+ badge.textContent = C.badge;
99
+ frame.appendChild(badge);
100
+ }
101
+
102
+ var hook = document.getElementById('hook');
103
+ var text = hook.querySelector('.t');
104
+ var marked = C.marked || [];
105
+ String(C.hook || '').split(' ').forEach(function (word, i) {
106
+ if (i) text.appendChild(document.createTextNode(' '));
107
+ var span = document.createElement('span');
108
+ span.textContent = word;
109
+ if (marked.indexOf(i) !== -1) span.style.color = accent;
110
+ text.appendChild(span);
111
+ });
112
+ var hookH = 720 - y0 - 64;
113
+ hook.style.top = y0 + 'px';
114
+ hook.style.height = hookH + 'px';
115
+ fit(text, 230, 48, hookH);
116
+ document.body.setAttribute('data-ready', '1');
117
+ </script>
118
+ </body>
119
+ </html>
@@ -26,7 +26,7 @@ const argv = process.argv.slice(2);
26
26
  const cmd = argv[0];
27
27
  const flag = (n, d) => { const i = argv.indexOf('--' + n); return i === -1 ? d : argv[i + 1]; };
28
28
 
29
- const STAGES = new Set(['3', '4', '4b', '5', '6', '7', '8']);
29
+ const STAGES = new Set(['3', '4', '4b', '5', '6', '6b', '7', '8']);
30
30
  const outDir = flag('out');
31
31
  const video = flag('video');
32
32
  const stage = flag('stage');
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ thumbnail.mjs — /thumbnail stage (stage 6b): the upload thumbnail.
4
+
5
+ The agent writes out/<name>/<NN>/thumbnail.json (the hook, a still from its own
6
+ stage-5 scenes, an optional crop, accent and badge); this tool adds the series label,
7
+ photographs src/thumbnail.html at 1280x720 with the headless Chromium, checks the PNG,
8
+ and copies it into the video's destination folder beside title.txt:
9
+
10
+ --dest/<NN>-<slug>/thumbnail.png — what upload_yt_video picks up
11
+
12
+ The series label reads "<playlist>-Part <X>, <upload title>". The playlist name only
13
+ ever comes from the user (--playlist); a plan of more than one video with neither
14
+ --playlist nor --standalone is refused with exit 3, so the agent asks instead of
15
+ inventing one. Part X is the video's 1-based position in plan.enriched.json's
16
+ `videos` list (after curation's skips and merges), not its raw videoIndex.
17
+
18
+ Usage:
19
+ node tools/thumbnail.mjs --work out/<name>/<NN> --script out/<name>/scripts/<NN>-*.json \
20
+ --plan out/<name>/plan.enriched.json --dest <destination> \
21
+ [--playlist "<name>" | --standalone]
22
+
23
+ Exit: 0 written · 1 a missing input or prerequisite · 2 the config
24
+ or the PNG fails a check · 3 a series with no playlist decision (ask the user).
25
+ ========================================================================== */
26
+
27
+ import { spawnSync } from 'node:child_process';
28
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
29
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { chromiumBinOrExit } from './chromium.mjs';
32
+ import { videoDir } from './naming.mjs';
33
+
34
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
35
+
36
+ export const WIDTH = 1280;
37
+ export const HEIGHT = 720;
38
+ // upload_yt_video refuses a thumbnail over 2 MB; the decimal reading is the stricter one
39
+ export const MAX_BYTES = 2_000_000;
40
+
41
+ /* ---------------- the PNG ---------------- */
42
+ const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
43
+
44
+ // width and height from the IHDR chunk, or null for anything that is not a PNG
45
+ export function pngInfo(buf) {
46
+ if (buf.length < 24 || !buf.subarray(0, 8).equals(PNG_SIG) || buf.toString('latin1', 12, 16) !== 'IHDR') return null;
47
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
48
+ }
49
+
50
+ // every way the file fails what the upload tool and YouTube accept
51
+ export function checkThumbnail(buf) {
52
+ const problems = [];
53
+ const info = pngInfo(buf);
54
+ if (!info) problems.push(/^\xff\xd8\xff/.test(buf.toString('latin1', 0, 3))
55
+ ? 'it is a JPEG named as a PNG: write a real PNG'
56
+ : 'not a PNG (no PNG signature and IHDR chunk)');
57
+ else if (info.width !== WIDTH || info.height !== HEIGHT) problems.push(`it is ${info.width}x${info.height}; a thumbnail is ${WIDTH}x${HEIGHT}`);
58
+ if (buf.length > MAX_BYTES) problems.push(`it is ${buf.length.toLocaleString('en-US')} bytes; the upload refuses more than ${MAX_BYTES.toLocaleString('en-US')}`);
59
+ return problems;
60
+ }
61
+
62
+ /* ---------------- the series label ---------------- */
63
+ // the video's place in the series as produced: its 1-based position among the plan's surviving videos
64
+ export function partOf(plan, videoIndex) {
65
+ const videos = (plan && plan.videos) || [];
66
+ const at = videos.findIndex((v) => Number(v.index) === Number(videoIndex));
67
+ return at === -1 ? null : { part: at + 1, of: videos.length };
68
+ }
69
+
70
+ export const seriesLabel = (playlist, part, title) => `${playlist}-Part ${part}, ${title}`;
71
+
72
+ /* ---------------- the config ---------------- */
73
+ const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
74
+ const words = (s) => norm(s).split(' ').filter(Boolean);
75
+ const STOP = new Set(['a', 'an', 'the', 'of', 'to', 'in', 'on', 'and', 'or', 'is', 'it', 'for', 'how', 'why', 'what', 'your', 'you']);
76
+
77
+ // one hook word as the template compares it: case and punctuation dropped, so "Zero-copy" and "zero-copy!" are one word
78
+ const wordKey = (w) => String(w).toLowerCase().replace(/[^\p{L}\p{N}']/gu, '');
79
+
80
+ // the positions of the hook's whitespace-separated words that a highlight names; the template colours exactly these
81
+ export function markedWords(hook, highlight) {
82
+ const keys = new Set([].concat(highlight || []).map(wordKey).filter(Boolean));
83
+ return hook.split(' ').flatMap((w, i) => (keys.has(wordKey(w)) ? [i] : []));
84
+ }
85
+
86
+ // a hook word that is code, not the viewer's terms: a path, an identifier, a module path or a call
87
+ const CODE = [
88
+ (w) => /^\.{1,2}\//.test(w),
89
+ (w) => w.includes('/') && w.split('/').some((s) => /^[\w-]+\.[A-Za-z][A-Za-z0-9]{0,4}$/.test(s)),
90
+ (w) => /[A-Za-z_][\w-]*(\/[A-Za-z_][\w-]*){2,}/.test(w),
91
+ (w) => /^[a-z][a-z0-9]+[A-Z][a-z]/.test(w),
92
+ (w) => /[A-Za-z0-9]_[A-Za-z0-9]/.test(w),
93
+ (w) => /^[a-z_]\w*(\.[A-Za-z_]\w*){2,}/.test(w),
94
+ (w) => /\w::\w/.test(w),
95
+ (w) => /\w\(\)/.test(w),
96
+ ];
97
+ export const codeWords = (hook) => hook.split(' ')
98
+ .filter((w) => { const bare = w.replace(/^[^\w./]+/, '').replace(/[^\w)]+$/, ''); return CODE.some((is) => is(bare)); });
99
+
100
+ // { errors, warnings } for thumbnail.json against the upload title; `codebase` adds the code-flow rule
101
+ export function checkConfig(cfg, { title = '', codebase = false } = {}) {
102
+ const errors = [];
103
+ const warnings = [];
104
+ const hook = typeof cfg.hook === 'string' ? cfg.hook.replace(/\s+/g, ' ').trim() : '';
105
+ if (!hook) errors.push('"hook" is missing: a few big words that make a viewer want the answer');
106
+ else {
107
+ const n = hook.split(' ').length;
108
+ if (n > 5) errors.push(`"hook" is ${n} words; at most 5 read at phone size`);
109
+ if (hook.length > 32) errors.push(`"hook" is ${hook.length} characters; at most 32 read at phone size`);
110
+ const h = norm(hook), t = norm(title);
111
+ if (t && (h === t || ` ${t} `.includes(` ${h} `))) errors.push(`"hook" repeats the upload title ("${title}"); it must add to it, not echo it`);
112
+ else if (t) {
113
+ const own = words(hook).filter((w) => !STOP.has(w));
114
+ const shared = own.filter((w) => words(title).includes(w));
115
+ if (own.length && shared.length / own.length > 0.5) warnings.push(`"hook" shares most of its words with the upload title (${shared.join(', ')}); a hook that complements the title earns the click`);
116
+ }
117
+ if (codebase) {
118
+ const code = codeWords(hook);
119
+ if (code.length) errors.push(`"hook" names code (${code.join(', ')}); a code-flow hook names the flow in the viewer's terms, never a module path or an identifier`);
120
+ }
121
+ for (const w of [].concat(cfg.highlight || [])) {
122
+ if (!markedWords(hook, w).length) errors.push(`"highlight" word "${w}" is not in the hook`);
123
+ }
124
+ }
125
+ if (cfg.highlight != null && !Array.isArray(cfg.highlight) && typeof cfg.highlight !== 'string') errors.push('"highlight" is a word of the hook or a list of them');
126
+ if (typeof cfg.still !== 'string' || !cfg.still.trim()) errors.push('"still" is missing: a PNG rendered from one of this video\'s own stage-5 scenes');
127
+ if (cfg.focus != null) {
128
+ const { x = 0.5, y = 0.5, zoom = 1 } = cfg.focus;
129
+ if (![x, y].every((v) => typeof v === 'number' && v >= 0 && v <= 1)) errors.push('"focus" x and y are fractions of the still, 0 to 1');
130
+ if (typeof zoom !== 'number' || zoom < 1 || zoom > 4) errors.push('"focus" zoom is 1 to 4');
131
+ }
132
+ if (cfg.accent != null && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(cfg.accent))) errors.push('"accent" is a hex colour such as "#FFD400"');
133
+ if (cfg.badge != null && (typeof cfg.badge !== 'string' || !cfg.badge.trim() || cfg.badge.length > 16)) errors.push('"badge" is a short sticker, 1 to 16 characters');
134
+ return { errors, warnings };
135
+ }
136
+
137
+ /* ---------------- CLI ---------------- */
138
+ function main(argv) {
139
+ const has = (n) => argv.includes('--' + n);
140
+ const flag = (n) => { const i = argv.indexOf('--' + n); return i === -1 ? undefined : argv[i + 1]; };
141
+ const die = (code, msg) => { console.error(`[thumbnail] ${msg}`); process.exit(code); };
142
+ const readJson = (p, what) => {
143
+ if (!existsSync(p)) die(1, `no ${what} at ${p}`);
144
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch (e) { die(1, `${what} at ${p} is not JSON: ${e.message}`); }
145
+ };
146
+ const report = (file, problems) => {
147
+ if (problems.length) {
148
+ console.error(`[thumbnail] ✗ ${file}`);
149
+ for (const p of problems) console.error(` - ${p}`);
150
+ process.exit(2);
151
+ }
152
+ };
153
+
154
+ const WORK = flag('work'), SCRIPT = flag('script'), PLAN = flag('plan'), DEST = flag('dest');
155
+ if (!WORK || !SCRIPT || !PLAN || !DEST)
156
+ die(1, 'usage: thumbnail.mjs --work <work-dir> --script <script.json> --plan <plan.enriched.json> --dest <dir> [--playlist "<name>" | --standalone]');
157
+ const workDir = resolve(WORK);
158
+ const script = readJson(resolve(SCRIPT), 'script');
159
+ const plan = readJson(resolve(PLAN), 'enriched plan');
160
+ const cfg = readJson(join(workDir, 'thumbnail.json'), 'thumbnail.json (write it first: hook + still)');
161
+
162
+ const where = partOf(plan, script.videoIndex);
163
+ if (!where) die(1, `video ${script.videoIndex} is not in ${PLAN}: pass the enriched plan, which lists the videos curation kept`);
164
+
165
+ const destDir = join(resolve(DEST), videoDir(script, basename(workDir)));
166
+ const titleFile = join(destDir, 'title.txt');
167
+ if (!existsSync(titleFile)) die(1, `no title.txt at ${titleFile}: run /publish (stage 6) first, the thumbnail needs the final upload title`);
168
+ const title = readFileSync(titleFile, 'utf8').trim();
169
+
170
+ /* The playlist name is the user's, never ours: no default, no guess. */
171
+ const playlist = flag('playlist');
172
+ if (playlist !== undefined && has('standalone')) die(1, 'pass --playlist or --standalone, not both');
173
+ if (playlist !== undefined && !String(playlist).trim()) die(1, '--playlist is empty: pass the user\'s playlist name, --standalone if they said there is none, or neither flag to be asked');
174
+ if (playlist === undefined && !has('standalone') && where.of > 1)
175
+ die(3, `this plan has ${where.of} videos, so video ${script.videoIndex} looks like part of a series, and no playlist name was given. ` +
176
+ 'Ask the user for the playlist name (or whether it is standalone), then re-run with --playlist "<name>" or --standalone. Never invent one.');
177
+ const label = playlist === undefined ? null : seriesLabel(String(playlist).trim(), where.part, title);
178
+
179
+ const { errors, warnings } = checkConfig(cfg, { title, codebase: plan.kind === 'codebase' });
180
+ const stillPath = typeof cfg.still === 'string' ? (isAbsolute(cfg.still) ? cfg.still : join(workDir, cfg.still)) : null;
181
+ if (stillPath && !existsSync(stillPath)) errors.push(`"still" ${cfg.still} does not exist (paths are relative to ${workDir})`);
182
+ for (const w of warnings) console.log(` ⚠ ${w}`);
183
+ report(join(workDir, 'thumbnail.json'), errors);
184
+
185
+ /* Photograph the host: everything is laid out before the load event, so --screenshot never races it. */
186
+ const bin = chromiumBinOrExit('thumbnail');
187
+ const hook = cfg.hook.replace(/\s+/g, ' ').trim();
188
+ const config = {
189
+ hook,
190
+ marked: markedWords(hook, cfg.highlight),
191
+ label,
192
+ still: pathToFileURL(stillPath).href,
193
+ focus: cfg.focus || null,
194
+ accent: cfg.accent || null,
195
+ badge: cfg.badge ? cfg.badge.trim() : null,
196
+ };
197
+ const url = pathToFileURL(join(ROOT, 'src', 'thumbnail.html')).href + '?c=' + encodeURIComponent(JSON.stringify(config));
198
+ const out = join(workDir, 'thumbnail.png');
199
+ rmSync(out, { force: true });
200
+ const shot = spawnSync(bin, ['--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
201
+ '--force-device-scale-factor=1', '--allow-file-access-from-files', '--disable-lcd-text', '--font-render-hinting=none',
202
+ `--window-size=${WIDTH},${HEIGHT}`, `--screenshot=${out}`, url], { encoding: 'utf8', timeout: 60_000 });
203
+ if (!existsSync(out)) die(1, `Chromium wrote no screenshot (exit ${shot.status}${shot.error ? `, ${shot.error.message}` : ''})\n${(shot.stderr || '').trim().split('\n').slice(-5).join('\n')}`);
204
+ report(out, checkThumbnail(readFileSync(out)));
205
+
206
+ /* The phone-size look: YouTube's mobile lists show a thumbnail a few hundred pixels wide. */
207
+ const small = join(workDir, 'thumbnail.small.png');
208
+ const scaled = spawnSync('ffmpeg', ['-y', '-v', 'error', '-i', out, '-vf', 'scale=320:180', small], { encoding: 'utf8' });
209
+ if (scaled.error || scaled.status !== 0) console.log(` ⚠ no phone-size preview (ffmpeg ${scaled.error ? 'not found' : 'failed'}); look at the full PNG at a small zoom instead`);
210
+
211
+ mkdirSync(destDir, { recursive: true });
212
+ copyFileSync(out, join(destDir, 'thumbnail.png'));
213
+ for (const other of ['thumbnail.jpg', 'thumbnail.jpeg']) {
214
+ if (existsSync(join(destDir, other))) console.log(` ⚠ ${join(destDir, other)} is also there; the upload may pick it instead of thumbnail.png, so remove it`);
215
+ }
216
+ console.log(` hook : "${config.hook}"`);
217
+ console.log(` label : ${label ? `"${label}"` : '— (standalone video)'}`);
218
+ console.log(` still : ${stillPath}`);
219
+ console.log(`\n[out] ${out}`);
220
+ if (existsSync(small)) console.log(`[out] ${small}`);
221
+ console.log(`[dest] ${join(destDir, 'thumbnail.png')}`);
222
+ }
223
+
224
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -65,7 +65,7 @@ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
65
65
  .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
66
  const fmtDur = (n) => n < 60 ? `${round1(n)}s` : `${Math.floor(n / 60)}m${Math.round(n % 60)}s`;
67
67
 
68
- const STAGE_NAMES = { '3': 'script', '4': 'audio', '4b': 'align', '5': 'scene', '6': 'publish', '7': 'render', '8': 'assemble' };
68
+ const STAGE_NAMES = { '3': 'script', '4': 'audio', '4b': 'align', '5': 'scene', '6': 'publish', '6b': 'thumbnail', '7': 'render', '8': 'assemble' };
69
69
  const PALETTE = ['#4f46e5', '#0ea5e9', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6',
70
70
  '#ec4899', '#10b981', '#f97316', '#6366f1', '#a855f7', '#22c55e'];
71
71