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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -6
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/craft-video-assemble/SKILL.md +2 -1
- package/skills/craft-video-thumbnail/SKILL.md +168 -0
- package/skills/explainer-video-from-codebase/SKILL.md +50 -11
- package/skills/explainer-video-from-coursework/ARCHITECTURE.md +2 -0
- package/skills/explainer-video-from-coursework/PIPELINE.md +26 -0
- package/skills/explainer-video-from-coursework/ROADMAP.md +7 -5
- package/skills/explainer-video-from-coursework/SERIES.md +13 -2
- package/skills/explainer-video-from-coursework/SKILL.md +150 -10
- package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
- package/skills/explainer-video-from-coursework/tools/stamp.mjs +1 -1
- package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
- package/skills/explainer-video-from-coursework/tools/timing-report.mjs +1 -1
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
|
|
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
|
|
105
|
-
chapter block) and `
|
|
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
|
|
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
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.
|
|
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,
|
|
18
|
-
assemble.
|
|
19
|
-
a title rule. Stage 5 uses `craft-codeflow-scene`: code
|
|
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)
|
|
39
|
-
> file's code-video rules for their stage, in a sync block that
|
|
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
|
|
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
|
|
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-
|
|
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` —
|
|
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
|
|
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
|
-
|
|
27
|
-
under `skills/`, so every platform that reads the plugin discovers all
|
|
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,
|
|
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
|
|
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 `
|
|
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
|
|
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, '&').replace(/</g, '<')
|
|
|
65
65
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
|