explainer-video 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +70 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/craft-video-thumbnail/SKILL.md +168 -0
  24. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  25. package/skills/explainer-video-from-codebase/SKILL.md +698 -0
  26. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  27. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  28. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  29. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  30. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  31. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  32. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  33. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  35. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  37. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  38. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  45. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  59. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  60. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  61. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  62. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  63. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  81. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  88. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  89. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  106. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  107. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
  108. package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
  109. package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
  110. package/skills/explainer-video-from-coursework/SERIES.md +243 -0
  111. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  112. package/skills/explainer-video-from-coursework/SKILL.md +998 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  118. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  120. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  121. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  122. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  124. package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
  125. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  126. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  127. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  128. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  129. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  130. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  131. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  132. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  133. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  134. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  135. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  136. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  137. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  138. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  139. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  140. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  141. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  142. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  143. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  144. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  145. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  146. package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
  147. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  148. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  149. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  150. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  151. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  152. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  153. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ script-check.mjs — validate a narration script before it costs anything.
4
+
5
+ The script writer is the AGENT (judgment). This tool is CODE (exactness), and
6
+ it enforces the same discipline the planner does for coverage:
7
+
8
+ 1. STRUCTURE unique scene keys, non-empty narration, a style per segment
9
+ 2. SPEAKABILITY no markdown, no URLs, no code syntax — this is read aloud
10
+ 3. BUDGET estimated narration + video duration vs the plan's target
11
+ 4. COVERAGE every source section the plan assigned must be narrated
12
+ 5. PAUSES code videos only, inert unless a segment carries `pause`:
13
+ a pause prompt is spoken ("pause" in its text), dwells
14
+ 0 < dwell <= 2 s realised as an equal `pad`, and a video has
15
+ at most 3; a fourth is an error that forces a split
16
+
17
+ Duration is ESTIMATED here, never trusted: the real number comes from measuring
18
+ the generated audio downstream.
19
+
20
+ Usage:
21
+ node tools/script-check.mjs --script out/<name>/scripts/NN-<slug>.json \
22
+ [--plan out/<name>/plan.enriched.json] [--max-min 25]
23
+ ========================================================================== */
24
+
25
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
26
+ import { join, basename } from 'node:path';
27
+ import { WPM as DEFAULT_WPM, EXPANSION as DEFAULT_EXPANSION, MAX_PAUSES } from './model.mjs';
28
+
29
+ const argv = process.argv.slice(2);
30
+ const flag = (n, d) => {
31
+ const i = argv.indexOf('--' + n);
32
+ return i === -1 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
33
+ };
34
+
35
+ /* same cost model as the planner, so the two are comparable */
36
+ const WPM = parseFloat(flag('wpm', DEFAULT_WPM));
37
+ const EXPANSION = parseFloat(flag('expansion', DEFAULT_EXPANSION));
38
+ const MAX_MIN = parseFloat(flag('max-min', '25'));
39
+ const MIN_FILL = parseFloat(flag('min-fill', '0.55')); // script must reach 55% of target
40
+
41
+ const scriptPath = flag('script');
42
+ const planPath = flag('plan');
43
+ if (!scriptPath) {
44
+ console.error('usage: node tools/script-check.mjs --script <script.json> [--plan plan.enriched.json]');
45
+ process.exit(1);
46
+ }
47
+ if (!existsSync(scriptPath)) { console.error('not found: ' + scriptPath); process.exit(1); }
48
+
49
+ const script = JSON.parse(readFileSync(scriptPath, 'utf8'));
50
+ const errors = [];
51
+ const warnings = [];
52
+
53
+ /* ---------------- helpers ---------------- */
54
+ const words = s => String(s || '').split(/\s+/).filter(Boolean).length;
55
+ const narrationMin = w => (w / WPM);
56
+ const videoMin = w => (w / WPM) * EXPANSION;
57
+
58
+ /* ---------------- 1. structure ---------------- */
59
+ if (!script.videoIndex && script.videoIndex !== 0) errors.push('missing videoIndex');
60
+ if (!script.title) errors.push('missing title');
61
+ if (!Array.isArray(script.segments) || !script.segments.length) errors.push('no segments');
62
+
63
+ const seen = new Set();
64
+ (script.segments || []).forEach((s, i) => {
65
+ const at = `segment ${i + 1}${s.key ? ` (${s.key})` : ''}`;
66
+ if (!s.key) errors.push(`${at}: missing key`);
67
+ else if (seen.has(s.key)) errors.push(`${at}: duplicate key "${s.key}"`);
68
+ else seen.add(s.key);
69
+
70
+ if (!s.text || !String(s.text).trim()) errors.push(`${at}: empty narration`);
71
+ if (!s.style) warnings.push(`${at}: no style — TTS will fall back to a generic delivery`);
72
+ if (!s.visual) warnings.push(`${at}: no visual hint — the scene stage will have to guess`);
73
+ if (!Array.isArray(s.covers) || !s.covers.length) warnings.push(`${at}: does not declare what it covers`);
74
+ });
75
+
76
+ /* ---------------- 2. speakability ---------------- */
77
+ (script.segments || []).forEach((s, i) => {
78
+ const t = String(s.text || '');
79
+ const at = `segment ${i + 1} (${s.key})`;
80
+ if (/https?:\/\//.test(t)) errors.push(`${at}: contains a URL — not speakable`);
81
+ if (/[*_`#>]|\|\s*-{2,}/.test(t)) errors.push(`${at}: contains markdown syntax`);
82
+ if (/[{}();=]|=>|\bdef \b|\bclass \w+\(/.test(t)) warnings.push(`${at}: looks like code in the narration`);
83
+ if (/\b[A-Z]{3,}\b/.test(t)) {
84
+ const acronyms = t.match(/\b[A-Z]{3,}\b/g);
85
+ if (acronyms) warnings.push(`${at}: acronym(s) ${[...new Set(acronyms)].join(', ')} — TTS may spell them out`);
86
+ }
87
+ if (words(t) > 130) warnings.push(`${at}: ${words(t)} words in one breath — consider splitting`);
88
+ });
89
+
90
+ /* ---------------- 5. pause prompts (active only when a segment carries `pause`) ----------------
91
+ Narration is the reading time: a code panel shows only the lines being narrated. A
92
+ pause prompt is the rare exception, for a block that must be read whole: the
93
+ narration says so out loud, a badge shows it, and the segment dwells at most 2 s
94
+ longer (realised as its `pad`). More than MAX_PAUSES means the video asks the
95
+ viewer to read silently too often: split it. */
96
+ const pauses = (script.segments || []).filter(s => s.pause != null);
97
+ let pauseDwell = 0;
98
+ if (pauses.length) {
99
+ pauses.forEach(s => {
100
+ const at = `segment ${script.segments.indexOf(s) + 1} (${s.key})`;
101
+ const dwell = s.pause && typeof s.pause === 'object' ? s.pause.dwell : undefined;
102
+ if (!/\bpause\b/i.test(String(s.text || ''))) errors.push(`${at}: a pause segment's narration must say "pause" — the spoken prompt is what tells the viewer to stop and read`);
103
+ if (typeof dwell !== 'number' || !(dwell > 0 && dwell <= 2)) errors.push(`${at}: pause.dwell must be a number of seconds, 0 < dwell <= 2 (got ${JSON.stringify(dwell)})`);
104
+ else {
105
+ pauseDwell += dwell;
106
+ if (Number(s.pad) !== dwell) errors.push(`${at}: pause.dwell ${dwell}s is realised as the segment's pad, which is ${JSON.stringify(s.pad)} — set "pad": ${dwell}`);
107
+ }
108
+ });
109
+ if (pauses.length > MAX_PAUSES) errors.push(`${pauses.length} pause prompts (max ${MAX_PAUSES}) — a video that asks for that much silent reading must be split`);
110
+ }
111
+
112
+ /* ---------------- 3. budget ---------------- */
113
+ const totalWords = (script.segments || []).reduce((a, s) => a + words(s.text), 0);
114
+ const estNarr = narrationMin(totalWords);
115
+ const estVideo = videoMin(totalWords) + pauseDwell / 60;
116
+ const target = script.planTargetMinutes;
117
+
118
+ if (estVideo > MAX_MIN) errors.push(`estimated ${estVideo.toFixed(1)} min exceeds the ${MAX_MIN} min cap`);
119
+ if (target && estVideo < target * MIN_FILL) {
120
+ errors.push(`estimated ${estVideo.toFixed(1)} min is under ${(MIN_FILL * 100) | 0}% of the ${target} min target — content likely missing`);
121
+ }
122
+ if (target && estVideo > target * 1.25) {
123
+ warnings.push(`estimated ${estVideo.toFixed(1)} min is over 125% of the ${target} min target`);
124
+ }
125
+
126
+ /* ---------------- 4. coverage vs the plan ---------------- */
127
+ let coverageLine = null;
128
+ if (planPath && existsSync(planPath)) {
129
+ const plan = JSON.parse(readFileSync(planPath, 'utf8'));
130
+ /* The plan was split at its own recorded cap. If this run's cap disagrees, the plan
131
+ is from a different model generation — say so rather than silently allowing a
132
+ script far longer than the plan ever expected. */
133
+ const planCap = plan.model && plan.model.maxVideoMin;
134
+ if (planCap != null && Math.abs(planCap - MAX_MIN) > 1e-9) {
135
+ warnings.push(`plan was built with maxVideoMin=${planCap} but this check uses ${MAX_MIN} — the plan predates the current default (re-plan to change the split, which also reshuffles video indices)`);
136
+ }
137
+ const video = plan.videos.find(v => v.index === script.videoIndex);
138
+ if (!video) {
139
+ warnings.push(`video ${script.videoIndex} not found in the plan — skipping coverage check`);
140
+ } else {
141
+ const assigned = new Set(video.sections);
142
+ const covered = new Set((script.segments || []).flatMap(s => s.covers || []));
143
+ const missing = [...assigned].filter(x => !covered.has(x));
144
+ const extra = [...covered].filter(x => !assigned.has(x));
145
+ missing.forEach(m => errors.push(`UNCOVERED source section: ${m}`));
146
+ extra.forEach(x => warnings.push(`covers a section not assigned to this video: ${x}`));
147
+ coverageLine = `${covered.size}/${assigned.size} assigned sections covered`;
148
+ }
149
+ }
150
+
151
+ /* ---------------- report ---------------- */
152
+ const name = basename(scriptPath);
153
+ console.log(`\n${name}`);
154
+ console.log('─'.repeat(Math.min(72, name.length + 20)));
155
+ console.log(` title : ${script.title || '(none)'}`);
156
+ console.log(` form : ${script.form || '(unset)'}`);
157
+ console.log(` segments : ${(script.segments || []).length}`);
158
+ console.log(` words : ${totalWords}`);
159
+ console.log(` narration : ${estNarr.toFixed(1)} min (at ${WPM} wpm)`);
160
+ console.log(` est. video : ${estVideo.toFixed(1)} min (×${EXPANSION} visual overhead)`);
161
+ if (pauses.length) console.log(` pauses : ${pauses.length} prompt(s), ${pauseDwell.toFixed(1)}s of dwell (in est. video)`);
162
+ if (target) console.log(` plan target : ${target} min → ${((estVideo / target) * 100).toFixed(0)}% of target`);
163
+ if (coverageLine) console.log(` coverage : ${coverageLine}`);
164
+ if (warnings.length) {
165
+ console.log(`\n ⚠ ${warnings.length} warning(s):`);
166
+ warnings.forEach(w => console.log(` - ${w}`));
167
+ }
168
+ if (errors.length) {
169
+ console.log(`\n ✗ ${errors.length} error(s):`);
170
+ errors.forEach(e => console.log(` - ${e}`));
171
+ } else {
172
+ console.log('\n ✓ script is valid');
173
+ }
174
+ process.exitCode = errors.length ? 2 : 0;
@@ -0,0 +1,71 @@
1
+ /* =============================================================================
2
+ stamp.mjs — append one per-video stage timing row to out/<name>/timing.jsonl.
3
+
4
+ Usage:
5
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage <n> [--note s]
6
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage <n> [--note s]
7
+
8
+ A stage spans many turns and several processes, so its duration cannot be read
9
+ directly: `begin` writes a throwaway start marker, `end` reads it, subtracts,
10
+ appends one row, and deletes the marker. The marker never enters the ledger.
11
+
12
+ One row per attempt, in long format — the stage dimension lives in the `stage`
13
+ value, never in a column name — so stages can be added, renamed, or merged
14
+ without ever changing the file's shape.
15
+
16
+ { video, stage, attempt, seconds, ended_at, note }
17
+
18
+ `attempt` is 1-based and auto-increments: it counts the rows already recorded for
19
+ this (video, stage) and adds one, so a retry becomes attempt 2 with no bookkeeping.
20
+ ========================================================================== */
21
+
22
+ import { mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ const argv = process.argv.slice(2);
26
+ const cmd = argv[0];
27
+ const flag = (n, d) => { const i = argv.indexOf('--' + n); return i === -1 ? d : argv[i + 1]; };
28
+
29
+ const STAGES = new Set(['3', '4', '4b', '5', '6', '6b', '7', '8']);
30
+ const outDir = flag('out');
31
+ const video = flag('video');
32
+ const stage = flag('stage');
33
+ const note = flag('note', null);
34
+
35
+ const die = (msg) => { console.error('[stamp] ' + msg); process.exit(1); };
36
+
37
+ if (!cmd || !['begin', 'end'].includes(cmd))
38
+ die('usage: stamp.mjs <begin|end> --out <seriesDir> --video <NN> --stage <n> [--note s]');
39
+ if (!outDir) die('missing --out <seriesDir>');
40
+ if (!video) die('missing --video <NN> (the bare plan index)');
41
+ if (!stage || !STAGES.has(stage)) die(`--stage must be one of ${[...STAGES].join(', ')}`);
42
+
43
+ mkdirSync(outDir, { recursive: true });
44
+ const stampsDir = join(outDir, '.stamps');
45
+ const marker = join(stampsDir, `${video}.${stage}.start`);
46
+ const ledger = join(outDir, 'timing.jsonl');
47
+
48
+ if (cmd === 'begin') {
49
+ mkdirSync(stampsDir, { recursive: true });
50
+ if (existsSync(marker))
51
+ console.error(`[stamp] overwriting an existing start marker for video ${video} stage ${stage} — did a previous \`end\` never run?`);
52
+ writeFileSync(marker, String(Date.now()));
53
+ console.log(`[stamp] begin video ${video} stage ${stage}`);
54
+ } else {
55
+ if (!existsSync(marker))
56
+ die(`no start marker for video ${video} stage ${stage} — run \`begin\` first`);
57
+ const startMs = Number(readFileSync(marker, 'utf8').trim());
58
+ const seconds = Math.round((Date.now() - startMs) / 100) / 10; // one decimal
59
+ let attempt = 1;
60
+ if (existsSync(ledger)) {
61
+ const prior = readFileSync(ledger, 'utf8').split('\n').filter(Boolean);
62
+ attempt = prior.reduce((n, line) => {
63
+ try { const r = JSON.parse(line); return (r.video === video && r.stage === stage) ? n + 1 : n; }
64
+ catch { return n; }
65
+ }, 0) + 1;
66
+ }
67
+ const row = { video, stage, attempt, seconds, ended_at: new Date().toISOString(), note };
68
+ appendFileSync(ledger, JSON.stringify(row) + '\n');
69
+ unlinkSync(marker);
70
+ console.log(`[stamp] video ${video} stage ${stage} attempt ${attempt} = ${seconds}s`);
71
+ }
@@ -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));