explainer-video 0.0.0-stage → 0.4.0

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 (150) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -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 +69 -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/explainer-video-from-codebase/FLOWS.md +577 -0
  24. package/skills/explainer-video-from-codebase/SKILL.md +659 -0
  25. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  26. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  27. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  28. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  29. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  30. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  31. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  32. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  33. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  35. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  37. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  38. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  45. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  59. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  60. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  61. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  62. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  63. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  81. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  88. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  89. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  106. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
  107. package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
  108. package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
  109. package/skills/explainer-video-from-coursework/SERIES.md +232 -0
  110. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  111. package/skills/explainer-video-from-coursework/SKILL.md +858 -0
  112. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  118. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  120. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  121. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  122. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  124. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  125. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  126. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  127. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  128. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  129. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  130. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  131. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  132. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  133. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  134. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  135. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  136. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  137. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  138. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  139. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  140. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  141. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  142. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  143. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  144. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  145. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  146. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  147. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  148. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  149. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  150. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ assemble.mjs — render every segment of a script and concatenate them.
4
+
5
+ Each segment owns a contiguous WINDOW of the finished video (emitted by the
6
+ audio stage). So assembly is:
7
+
8
+ for each segment:
9
+ render its window slice through its own host + config -> SILENT part
10
+ concatenate the parts -> full video
11
+ mux the single measured narration track -> final
12
+ verify the mux tracks the narration -> guard
13
+
14
+ Parts are rendered silent on purpose. Muxing narration onto each part would
15
+ restart the whole track at every segment boundary — which is exactly the bug
16
+ this file used to have.
17
+
18
+ Usage:
19
+ node tools/assemble.mjs --script out/<name>/scripts/NN-<slug>.json \
20
+ --work out/<name>/<NN> --name <output> \
21
+ --dest <destination> [--port 9333]
22
+
23
+ `--port` (default 9333) is forwarded to every per-segment render.mjs. Chromium's
24
+ CDP port is the ONE resource two concurrent assemblies share, and sharing it does
25
+ not fail cleanly: the second render attaches to the first browser's page and
26
+ encodes the FIRST video's scene under its own name (measured on two concurrent
27
+ runs — identical decoded frame hashes, exit 0, a valid MP4 of the wrong video).
28
+ Because that failure is silent, the port is asserted free before segment one.
29
+ ========================================================================== */
30
+
31
+ import { execFileSync } from 'node:child_process';
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, readdirSync, copyFileSync } from 'node:fs';
33
+ import { join, resolve, dirname, basename } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { createConnection } from 'node:net';
36
+ import { requireCmd } from './env.mjs';
37
+ import { videoDir } from './naming.mjs';
38
+ import { claimWorkDir } from './workdir.mjs';
39
+
40
+ requireCmd('ffmpeg');
41
+ requireCmd('ffprobe');
42
+
43
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
44
+ const argv = process.argv.slice(2);
45
+ const flag = (n, d) => {
46
+ const i = argv.indexOf('--' + n);
47
+ return i === -1 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
48
+ };
49
+
50
+ const SCRIPT = flag('script');
51
+ const WORK = String(flag('work', 'out/video'));
52
+ const NAME = String(flag('name', 'video'));
53
+ const FPS = String(flag('fps', '30'));
54
+ const ONLY = flag('only', null);
55
+ const KEEP = !!flag('keep-parts', false);
56
+ const TOLERANCE = parseFloat(flag('audio-tolerance', '1.5')); // dB
57
+ const DEST = flag('dest', null); // where to copy the finished mp4 (never committed)
58
+ const PORT = String(flag('port', '9333')); // CDP port, forwarded to every segment render.
59
+ // One per concurrent assembly (SERIES.md rule 2).
60
+
61
+ /* Refuse a port someone else already owns. Without this, render.mjs attaches to the
62
+ other browser's page and encodes the wrong video with no error (see header). */
63
+ function isPortFree(port) {
64
+ return new Promise((resolve) => {
65
+ const sock = createConnection({ host: '127.0.0.1', port: Number(port) });
66
+ sock.once('connect', () => { sock.destroy(); resolve(false); }); // someone is listening
67
+ sock.once('error', () => { sock.destroy(); resolve(true); }); // ECONNREFUSED -> free
68
+ setTimeout(() => { sock.destroy(); resolve(true); }, 750);
69
+ });
70
+ }
71
+
72
+ if (!SCRIPT || !existsSync(SCRIPT)) {
73
+ console.error('usage: node tools/assemble.mjs --script <script.json> --work <dir> --name <output>');
74
+ process.exit(1);
75
+ }
76
+
77
+ const run = (cmd, args) => execFileSync(cmd, args, { stdio: 'inherit' });
78
+ const ffprobeDur = f =>
79
+ parseFloat(execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f]).toString());
80
+
81
+ const script = JSON.parse(readFileSync(SCRIPT, 'utf8'));
82
+
83
+ /* One work dir per video (see tools/workdir.mjs) — parts/, the silent cut and the
84
+ final MP4 all have fixed names, so a shared dir would overwrite another video. */
85
+ claimWorkDir(WORK, script, 'assemble');
86
+ /* Per-video manifest: <work>/manifest.json. Falls back to the legacy global path so
87
+ a work dir that hasn't been migrated yet still assembles. */
88
+ const manifestPath = existsSync(join(WORK, 'manifest.json'))
89
+ ? join(WORK, 'manifest.json')
90
+ : join(ROOT, 'scenes', 'manifest.json');
91
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
92
+ const timelinePath = join(WORK, 'timeline.json');
93
+ if (!existsSync(timelinePath)) {
94
+ console.error(`no timeline at ${timelinePath} — run tools/build-audio.mjs first`);
95
+ process.exit(1);
96
+ }
97
+ const tl = JSON.parse(readFileSync(timelinePath, 'utf8'));
98
+ const windows = tl.windows || {};
99
+ const narration = join(WORK, 'narration.wav');
100
+
101
+ /* ---------------- pre-flight: nothing missing, windows must tile ---------------- */
102
+ const errors = [];
103
+ script.segments.forEach(sg => {
104
+ if (!windows[sg.key]) errors.push(`segment "${sg.key}" has no window in the timeline`);
105
+ if (!manifest.segments[sg.key]) errors.push(`segment "${sg.key}" has no entry in manifest.json`);
106
+ });
107
+ let cursor = 0;
108
+ for (const sg of script.segments) {
109
+ const w = windows[sg.key];
110
+ if (!w) continue;
111
+ if (Math.abs(w.start - cursor) > 0.01) errors.push(`window gap/overlap at "${sg.key}": expected ${cursor.toFixed(2)}, got ${w.start}`);
112
+ cursor = w.end;
113
+ }
114
+ if (Math.abs(cursor - tl.total) > 0.05) errors.push(`windows end at ${cursor.toFixed(2)} but the timeline is ${tl.total}s`);
115
+ if (errors.length) {
116
+ console.error('\n[assemble] cannot proceed:');
117
+ errors.forEach(e => console.error(' ✗ ' + e));
118
+ process.exit(1);
119
+ }
120
+
121
+ const order = script.segments.map(s => s.key).filter(k => !ONLY || k === ONLY);
122
+ const partDir = resolve(WORK, 'parts');
123
+ mkdirSync(partDir, { recursive: true });
124
+
125
+ console.log(`[assemble] ${order.length} segment(s) · timeline ${tl.total}s · narration ${existsSync(narration) ? 'present' : 'MISSING'} · port ${PORT}\n`);
126
+
127
+ /* ---------------- render each window, SILENT ---------------- */
128
+ if (!(await isPortFree(PORT))) {
129
+ console.error(`\n[assemble] port ${PORT} is already in use — another render/assemble owns it.`);
130
+ console.error(`[assemble] Give this run its own --port (e.g. --port ${Number(PORT) + 1}).`);
131
+ console.error('[assemble] Sharing a CDP port does NOT fail cleanly: the second run attaches to the');
132
+ console.error(' first browser and encodes the other video\'s frames under its own name.');
133
+ process.exit(1);
134
+ }
135
+ for (const key of order) {
136
+ const w = windows[key];
137
+ const m = manifest.segments[key];
138
+ const host = manifest.hosts[m.host];
139
+ if (!host) { console.error(`unknown host "${m.host}" for ${key}`); process.exit(1); }
140
+ console.log(`▶ ${key.padEnd(7)} ${String(w.dur).padStart(6)}s (t ${w.start} → ${w.end}) ${m.host}/${m.scene}`);
141
+ run('node', [join(ROOT, 'tools', 'render.mjs'),
142
+ '--scene', `${host}?scene=${m.scene}`,
143
+ '--work', WORK,
144
+ '--range', `${w.start},${w.end}`,
145
+ '--name', `part-${key}`,
146
+ '--outdir', join(WORK, 'parts'),
147
+ '--fps', FPS,
148
+ '--port', PORT,
149
+ ]);
150
+ }
151
+
152
+ /* ---------------- concatenate (identical codec/size/fps -> stream copy) ---------------- */
153
+ const parts = order.map(k => join(partDir, `part-${k}.mp4`)).filter(existsSync);
154
+ if (parts.length !== order.length) {
155
+ console.error(`\n[assemble] expected ${order.length} parts, found ${parts.length}`);
156
+ process.exit(1);
157
+ }
158
+ const listFile = join(partDir, 'concat.txt');
159
+ writeFileSync(listFile, parts.map(p => `file '${resolve(p)}'`).join('\n') + '\n');
160
+
161
+ const outDir = resolve(WORK);
162
+ const silent = join(outDir, `${NAME}-silent.mp4`);
163
+ const final = join(outDir, `${NAME}.mp4`);
164
+
165
+ console.log('\n[assemble] concatenating…');
166
+ run('ffmpeg', ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', silent]);
167
+
168
+ /* ---------------- mux the narration ONCE ---------------- */
169
+ if (existsSync(narration)) {
170
+ console.log('[assemble] muxing narration (once)…');
171
+ run('ffmpeg', ['-y', '-v', 'error', '-i', silent, '-i', narration,
172
+ '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-af', 'apad', '-shortest',
173
+ '-movflags', '+faststart', final]);
174
+ rmSync(silent, { force: true });
175
+ } else {
176
+ console.log('[assemble] no narration.wav — leaving a silent cut');
177
+ rmSync(final, { force: true });
178
+ execFileSync('mv', [silent, final]);
179
+ }
180
+
181
+ /* ---------------- VERIFY: audio must match the narration sample-for-sample ----------------
182
+ RMS was too weak a test: speech sits at a roughly constant level, so a part
183
+ playing the WRONG slice can still look right. verify-audio.mjs compares the
184
+ residual energy after subtraction, which separates "same audio" (near 0) from
185
+ "different audio" (~1.4) unambiguously. */
186
+ let verdict = 'skipped (no narration)';
187
+ if (existsSync(narration)) {
188
+ console.log('\n[verify] finished audio vs narration track…');
189
+ try {
190
+ const out = execFileSync('node', [join(ROOT, 'tools', 'verify-audio.mjs'),
191
+ '--video', final, '--narration', narration], { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
192
+ process.stdout.write(out);
193
+ verdict = 'ok';
194
+ } catch (e) {
195
+ process.stdout.write(String(e.stdout || ''));
196
+ process.stderr.write(String(e.stderr || ''));
197
+ console.error(' ✗ a part is playing the wrong slice of narration (double mux?)');
198
+ process.exit(2);
199
+ }
200
+ }
201
+
202
+ /* ---------------- report ---------------- */
203
+ const dur = ffprobeDur(final);
204
+ console.log(`\n[done] ${final}`);
205
+ console.log(` ${parts.length} segments · ${dur.toFixed(2)}s · ${(dur / 60).toFixed(2)} min · audio ${verdict}`);
206
+
207
+ /* ---------------- deliver: the mp4 is the only thing the user keeps ---------
208
+ Copy it to a destination they own; the work dir stays scratch and disposable. */
209
+ if (DEST) {
210
+ /* The finished MP4 goes into the video's own folder — the same <NN>-<slug> that
211
+ publish.mjs uses for description.md / captions.srt — so a series sorts into
212
+ consumption order and the three deliverables never collide across videos. */
213
+ const dir = join(resolve(DEST), videoDir(script, basename(WORK)));
214
+ mkdirSync(dir, { recursive: true });
215
+ const destPath = join(dir, NAME + '.mp4');
216
+ copyFileSync(final, destPath);
217
+ console.log(`[dest] ${destPath}`);
218
+ }
219
+
220
+ if (!KEEP) {
221
+ for (const f of readdirSync(partDir)) rmSync(join(partDir, f), { force: true });
222
+ console.log('[clean] parts removed (use --keep-parts to retain)');
223
+ }
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ build-audio.mjs — the /audio stage: narration script -> measured audio.
4
+
5
+ This is the ONLY stage that calls an API (Gemini TTS). Everything else in the
6
+ pipeline runs offline.
7
+
8
+ It does not estimate. It synthesizes, MEASURES the real duration of every
9
+ segment with ffprobe, and emits a timeline of absolute start/end times. Scene
10
+ timings are then bound to those measurements rather than to a wpm guess.
11
+
12
+ Usage:
13
+ node tools/build-audio.mjs --script out/<name>/scripts/NN-<slug>.json \
14
+ --out out/<name>/<NN> \
15
+ [--only <key>] [--voice Aoede] [--model gemini-3.8-flash-lite-tts] [--interactive]
16
+ ========================================================================== */
17
+
18
+ import { execFileSync } from 'node:child_process';
19
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
20
+ import { join, dirname, resolve } from 'node:path';
21
+ import { synthesize, synthesizeBatch, DEFAULT_MODEL, FALLBACK_MODEL } from './tts-gemini.mjs';
22
+ import { loadKey, describe, storeHint } from './key-source.mjs';
23
+ import { claimWorkDir } from './workdir.mjs';
24
+ import { requireCmd } from './env.mjs';
25
+
26
+ requireCmd('ffmpeg');
27
+ requireCmd('ffprobe');
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
+ const SCRIPT = flag('script');
36
+ const OUT_DIR = String(flag('out', 'out'));
37
+ const ONLY = flag('only', null); // synthesize just one segment key
38
+ const VOICE = String(flag('voice', 'Aoede'));
39
+ const MODEL = String(flag('model', 'gemini-3.8-flash-lite-tts'));
40
+ const INTERACTIVE = !!flag('interactive', false); // opt OUT of batch: per-segment, synchronous
41
+ const BATCH = !INTERACTIVE; // default: one batch job for all pending segments
42
+ const FALLBACK = MODEL === FALLBACK_MODEL ? DEFAULT_MODEL : FALLBACK_MODEL; // the other TTS model
43
+ const LEAD = parseFloat(flag('lead', '0.6')); // silence before the first word
44
+ const GAP = parseFloat(flag('gap', '0.75')); // silence between segments
45
+ const TAIL = parseFloat(flag('tail', '1.6')); // silence after the last word
46
+ const SR = 22050;
47
+
48
+ if (!SCRIPT || !existsSync(SCRIPT)) {
49
+ console.error('usage: node tools/build-audio.mjs --script <script.json> --out <dir> [--only key]');
50
+ process.exit(1);
51
+ }
52
+
53
+ const run = (cmd, args) => execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
54
+ const durationOf = f => parseFloat(
55
+ run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f]).toString().trim()
56
+ );
57
+
58
+ const script = JSON.parse(readFileSync(SCRIPT, 'utf8'));
59
+
60
+ /* One work dir per video, or the fixed artefact names collide: a second video's
61
+ run would overwrite the first's timeline.json and everything downstream would
62
+ read the wrong video's timings. Checked before any filesystem or API work. */
63
+ claimWorkDir(OUT_DIR, script, 'build-audio');
64
+
65
+ const narDir = join(OUT_DIR, 'narration');
66
+ mkdirSync(narDir, { recursive: true });
67
+
68
+ const { key, source: keySrc, store } = loadKey(flag('key'));
69
+ if (!key) { console.error('No API key found. Store it in your OS secret store.\n' + storeHint(store)); process.exit(1); }
70
+ console.log(`[auth] ${describe(keySrc, key)}`);
71
+ console.log(`[model] ${MODEL} voice=${VOICE}${BATCH ? ` batch (fallback ${FALLBACK})` : ''}`);
72
+
73
+ const wanted = script.segments.filter(s => !ONLY || s.key === ONLY);
74
+ if (!wanted.length) { console.error(`no segment matched --only ${ONLY}`); process.exit(1); }
75
+
76
+ const segments = [];
77
+ let cursor = LEAD;
78
+ let calls = 0;
79
+ let batchJobs = 0;
80
+
81
+ /* ---- phase 1: ensure every wanted segment has its WAV on disk ---- */
82
+ const missing = [];
83
+ const wasCached = new Map();
84
+ for (const seg of wanted) {
85
+ const wav = join(narDir, `${seg.key}.wav`);
86
+ const srcFile = join(narDir, `${seg.key}.src`);
87
+ const cached = existsSync(wav) && existsSync(srcFile) && readFileSync(srcFile, 'utf8') === seg.text;
88
+ wasCached.set(seg.key, cached && !flag('force'));
89
+ if (cached && !flag('force')) {
90
+ console.log(` [tts] ${seg.key.padEnd(7)} reused (text unchanged)`);
91
+ } else {
92
+ missing.push(seg);
93
+ }
94
+ }
95
+
96
+ if (missing.length) {
97
+ if (BATCH) {
98
+ const { segments: made, jobs } = await synthesizeBatch({
99
+ segments: missing.map(s => ({ key: s.key, text: s.text, style: s.style })),
100
+ voice: VOICE, model: MODEL, fallbackModel: FALLBACK, key,
101
+ });
102
+ batchJobs += jobs;
103
+ for (const { key: k, wav: buf } of made) {
104
+ const seg = missing.find(s => s.key === k);
105
+ const raw = join(narDir, `${k}.raw.wav`);
106
+ const wavPath = join(narDir, `${k}.wav`);
107
+ writeFileSync(raw, buf);
108
+ run('ffmpeg', ['-y', '-v', 'error', '-i', raw, '-ar', String(SR), '-ac', '1', wavPath]);
109
+ rmSync(raw, { force: true });
110
+ writeFileSync(join(narDir, `${k}.src`), seg.text);
111
+ calls++;
112
+ console.log(` [tts] ${k.padEnd(7)} ${durationOf(wavPath).toFixed(2)}s (${seg.text.split(/\s+/).filter(Boolean).length} words)`);
113
+ }
114
+ } else {
115
+ for (const seg of missing) {
116
+ process.stdout.write(` [tts] ${seg.key.padEnd(7)} … `);
117
+ try {
118
+ const { wav: buf } = await synthesize({ text: seg.text, voice: VOICE, style: seg.style, model: MODEL, key });
119
+ const raw = join(narDir, `${seg.key}.raw.wav`);
120
+ writeFileSync(raw, buf);
121
+ run('ffmpeg', ['-y', '-v', 'error', '-i', raw, '-ar', String(SR), '-ac', '1', join(narDir, `${seg.key}.wav`)]);
122
+ rmSync(raw, { force: true });
123
+ writeFileSync(join(narDir, `${seg.key}.src`), seg.text);
124
+ calls++;
125
+ console.log(`${durationOf(join(narDir, `${seg.key}.wav`)).toFixed(2)}s (${seg.text.split(/\s+/).filter(Boolean).length} words)`);
126
+ } catch (e) {
127
+ console.log('FAILED');
128
+ console.error('\n[error] ' + e.message.split('\n')[0]);
129
+ process.exit(1);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ /* ---- phase 2: measure every segment and build the timeline ---- */
136
+ for (const seg of wanted) {
137
+ const dur = durationOf(join(narDir, `${seg.key}.wav`));
138
+ // A segment may declare a hold: extra silence after the spoken words, giving the
139
+ // visuals room a beat needs beyond its narration. pacing-check reports the
140
+ // shortfall; this pad resolves it (and re-running is free — the TTS is cached).
141
+ const pad = Number(seg.pad) || 0;
142
+ segments.push({ key: seg.key, start: +cursor.toFixed(3), end: +(cursor + dur).toFixed(3), duration: +dur.toFixed(3), pad: pad, words: seg.text.split(/\s+/).filter(Boolean).length, cached: !!wasCached.get(seg.key) });
143
+ cursor += dur + pad + GAP;
144
+ }
145
+
146
+ const total = +(cursor - GAP + TAIL).toFixed(3);
147
+
148
+ /* ---- partial runs must not touch the global artefacts ----
149
+ `--only` is a smoke test ("does TTS work for this segment?"). It used to write
150
+ the concatenated `narration.wav`, `timeline.json` and `timeline.js` from that
151
+ one segment, silently replacing a finished video's timeline with a one-scene
152
+ version that no downstream stage could use. A partial run now stops here with a
153
+ segment-scoped timeline and leaves the global artefacts exactly as they were. */
154
+ const complete = wanted.length === script.segments.length;
155
+ if (!complete) {
156
+ const suffix = wanted.map(s => s.key).join('+');
157
+ const partialPath = join(OUT_DIR, `timeline.${suffix}.json`);
158
+ writeFileSync(partialPath, JSON.stringify({
159
+ script: SCRIPT, voice: VOICE, model: MODEL, measured: true, partial: true,
160
+ requested: wanted.length, of: script.segments.length, total, segments,
161
+ }, null, 2));
162
+
163
+ const hadGlobal = existsSync(join(OUT_DIR, 'timeline.json'));
164
+ console.log(`\n[partial] ${wanted.length} of ${script.segments.length} segment(s) requested — a partial run.`);
165
+ console.log(`[partial] NOT written: narration.wav, timeline.json, timeline.js${hadGlobal ? ' (an existing timeline.json was left untouched)' : ''}`);
166
+ console.log(`[partial] measured timeline: ${partialPath}`);
167
+ console.log(`[partial] re-run without --only to rebuild the full track.`);
168
+ process.exit(0);
169
+ }
170
+
171
+ /* ---- build the full narration track (silence + segments + silence) ---- */
172
+ const sil = (name, secs) => {
173
+ const p = join(narDir, `silence-${name}.wav`);
174
+ run('ffmpeg', ['-y', '-v', 'error', '-f', 'lavfi', '-i', `anullsrc=r=${SR}:cl=mono`, '-t', String(secs), p]);
175
+ return p;
176
+ };
177
+
178
+ const parts = [];
179
+ if (LEAD > 0) parts.push(sil('lead', LEAD));
180
+ wanted.forEach((seg, i) => {
181
+ parts.push(join(narDir, `${seg.key}.wav`));
182
+ const pad = Number(seg.pad) || 0;
183
+ if (pad > 0) parts.push(sil(`${seg.key}-hold`, pad));
184
+ const isLast = i === wanted.length - 1;
185
+ const gap = isLast ? TAIL : GAP;
186
+ if (gap > 0) parts.push(sil(isLast ? 'tail' : 'gap', gap));
187
+ });
188
+ // ffmpeg resolves entries in a concat list RELATIVE TO THE LIST FILE, so every
189
+ // entry must be absolute or paths silently double up.
190
+ const listFile = join(narDir, 'concat.txt');
191
+ writeFileSync(listFile, parts.map(p => `file '${resolve(p)}'`).join('\n') + '\n');
192
+ run('ffmpeg', ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', listFile, '-ar', String(SR), '-ac', '1', join(OUT_DIR, 'narration.wav')]);
193
+ const trackDur = durationOf(join(OUT_DIR, 'narration.wav'));
194
+
195
+ // Each segment OWNS a contiguous slice of the finished video: from its lead-in
196
+ // to the next segment's lead-in. These tile the whole timeline with no gaps, so
197
+ // per-segment renders can simply be concatenated.
198
+ const windows = {};
199
+ const grandTotal = Math.max(total, trackDur);
200
+ segments.forEach((sg, i) => {
201
+ const wStart = i === 0 ? 0 : sg.start - LEAD;
202
+ const wEnd = i === segments.length - 1 ? grandTotal : segments[i + 1].start - LEAD;
203
+ windows[sg.key] = { start: +wStart.toFixed(3), end: +wEnd.toFixed(3), dur: +(wEnd - wStart).toFixed(3) };
204
+ });
205
+
206
+ const timeline = {
207
+ script: SCRIPT,
208
+ voice: VOICE,
209
+ model: MODEL,
210
+ measured: true,
211
+ total: grandTotal,
212
+ segments,
213
+ windows,
214
+ calls,
215
+ };
216
+ writeFileSync(join(OUT_DIR, 'timeline.json'), JSON.stringify(timeline, null, 2));
217
+
218
+ // scenes: a browser host loads this with <script> — file:// cannot fetch JSON
219
+ // Per-video: the browser-loadable timeline lives beside timeline.json, so a second
220
+ // video no longer overwrites a global src/timeline.js (ROADMAP §1, "Work-dir layout").
221
+ const scenes = {};
222
+ segments.forEach(sg => { scenes[sg.key] = { start: sg.start, end: sg.end, dur: sg.duration }; });
223
+ writeFileSync(join(OUT_DIR, 'timeline.js'),
224
+ 'window.TIMELINE = ' + JSON.stringify({
225
+ fps: 30, width: 1920, height: 1080,
226
+ total: grandTotal, scenes, windows, segments,
227
+ }, null, 2) + ';\n');
228
+
229
+ console.log(`\n[audio] ${calls} segment(s) synthesized${BATCH ? ` via ${batchJobs} batch job(s)` : ''} · narration track ${trackDur.toFixed(2)}s`);
230
+ console.log(`[windows] ${Object.entries(windows).map(([k, w]) => k + ' ' + w.dur + 's').join(' · ')}`);
231
+ segments.forEach(s => console.log(` ${s.key.padEnd(7)} ${String(s.start).padStart(7)}s -> ${String(s.end).padStart(7)}s (${s.duration}s)`));
232
+ console.log(`[out] ${join(OUT_DIR, 'narration.wav')}`);
233
+ console.log(`[out] ${join(OUT_DIR, 'timeline.json')}`);
234
+ console.log(`[out] ${join(OUT_DIR, 'timeline.js')} (browser-loadable, ${segments.length} scene(s))`);
@@ -0,0 +1,73 @@
1
+ // chromium.mjs — locate the Playwright headless-Chromium binary.
2
+ //
3
+ // The single source of truth for "where is the browser". Both the renderer
4
+ // (render.mjs) and the scene-boot debugger (debug-page.mjs) import this, so the
5
+ // platform/architecture logic lives in exactly one place (ROADMAP §2.1).
6
+ //
7
+ // Resolution order:
8
+ // 1. CHROME_BIN — explicit binary path
9
+ // 2. PLAYWRIGHT_BROWSERS_PATH — explicit cache root
10
+ // 3. the platform default cache root
11
+
12
+ import { existsSync, readdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import os from 'node:os';
15
+
16
+ const REVISION_PREFIX = 'chromium_headless_shell-';
17
+ const BUILD_PREFIX = 'chrome-headless-shell-';
18
+
19
+ function defaultCacheRoot() {
20
+ switch (process.platform) {
21
+ case 'darwin':
22
+ return join(os.homedir(), 'Library/Caches/ms-playwright');
23
+ case 'win32':
24
+ return join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'ms-playwright');
25
+ default:
26
+ return join(process.env.XDG_CACHE_HOME || join(os.homedir(), '.cache'), 'ms-playwright');
27
+ }
28
+ }
29
+
30
+ export function findChromium() {
31
+ if (process.env.CHROME_BIN) {
32
+ if (!existsSync(process.env.CHROME_BIN)) {
33
+ throw new Error(`CHROME_BIN is set but not found: ${process.env.CHROME_BIN}`);
34
+ }
35
+ return process.env.CHROME_BIN;
36
+ }
37
+
38
+ const base = process.env.PLAYWRIGHT_BROWSERS_PATH || defaultCacheRoot();
39
+ if (!existsSync(base)) {
40
+ throw new Error(`Playwright cache not found at ${base} (set PLAYWRIGHT_BROWSERS_PATH or CHROME_BIN)`);
41
+ }
42
+
43
+ const revision = readdirSync(base)
44
+ .filter(d => d.startsWith(REVISION_PREFIX))
45
+ .sort()
46
+ .pop();
47
+ if (!revision) throw new Error(`No ${REVISION_PREFIX}* build found in ${base}`);
48
+
49
+ const revisionDir = join(base, revision);
50
+ // The binary sits one directory down, and that directory is named per
51
+ // platform+architecture (chrome-headless-shell-mac-arm64, ...-linux64, etc.).
52
+ // Globbing the prefix — rather than hardcoding the name — is what makes this
53
+ // portable instead of Apple-Silicon-only.
54
+ const build = readdirSync(revisionDir).find(d => d.startsWith(BUILD_PREFIX));
55
+ if (!build) throw new Error(`No ${BUILD_PREFIX}* directory found in ${revisionDir}`);
56
+
57
+ const binName = process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
58
+ const bin = join(revisionDir, build, binName);
59
+ if (!existsSync(bin)) throw new Error(`Chromium binary not found at ${bin}`);
60
+ return bin;
61
+ }
62
+
63
+ /** findChromium, or print the one-time setup and exit — for the CLI entry points. */
64
+ export function chromiumBinOrExit(tool) {
65
+ try {
66
+ return findChromium();
67
+ } catch (err) {
68
+ console.error(`[${tool}] headless Chromium not found:\n ${err.message}`);
69
+ console.error(' One-time install: npx playwright install --with-deps --only-shell');
70
+ console.error(' Full setup: see SETUP.md (Node ≥ 22, ffmpeg, Chromium, Gemini key).');
71
+ process.exit(1);
72
+ }
73
+ }
@@ -0,0 +1,75 @@
1
+ // Load a scene page in headless chromium and print any console output or
2
+ // uncaught exceptions. A page that fails to boot reports as
3
+ // "never exposed window.VIDEO_META", which tells you nothing about WHY.
4
+ //
5
+ // node tools/debug-page.mjs src/scene-diagram.html
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { resolve } from 'node:path';
9
+ import { chromiumBinOrExit } from './chromium.mjs';
10
+ import { requireNode } from './env.mjs';
11
+
12
+ requireNode();
13
+
14
+ const page = process.argv[2] || 'src/scene-diagram.html';
15
+ const BIN = chromiumBinOrExit('debug-page');
16
+ const PORT = 9444;
17
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
18
+
19
+ const proc = spawn(BIN, ['--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
20
+ '--allow-file-access-from-files', `--remote-debugging-port=${PORT}`, '--window-size=1920,1080',
21
+ 'file://' + resolve(page)], { stdio: 'ignore' });
22
+
23
+ try {
24
+ let target = null;
25
+ for (let i = 0; i < 40; i++) {
26
+ await sleep(250);
27
+ try {
28
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
29
+ target = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl);
30
+ if (target) break;
31
+ } catch {}
32
+ }
33
+ if (!target) throw new Error('no page target');
34
+
35
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
36
+ let id = 0; const pending = new Map();
37
+ const logs = [];
38
+ ws.addEventListener('message', e => {
39
+ const m = JSON.parse(e.data);
40
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); return; }
41
+ if (m.method === 'Runtime.consoleAPICalled') {
42
+ logs.push(`console.${m.params.type}: ` + m.params.args.map(a => a.value ?? a.description ?? a.type).join(' '));
43
+ }
44
+ if (m.method === 'Runtime.exceptionThrown') {
45
+ const d = m.params.exceptionDetails;
46
+ logs.push(`EXCEPTION: ${d.exception?.description || d.text}`);
47
+ }
48
+ if (m.method === 'Log.entryAdded') logs.push(`log.${m.params.entry.level}: ${m.params.entry.text}`);
49
+ });
50
+ await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', rej, { once: true }); });
51
+ const send = (method, params = {}) => new Promise((res, rej) => {
52
+ const myId = ++id;
53
+ pending.set(myId, m => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result));
54
+ ws.send(JSON.stringify({ id: myId, method, params }));
55
+ });
56
+
57
+ await send('Runtime.enable');
58
+ await send('Log.enable');
59
+ await send('Page.enable');
60
+ await send('Page.reload'); // reload so we capture boot-time errors
61
+ await sleep(2500);
62
+
63
+ const state = await send('Runtime.evaluate', {
64
+ expression: 'JSON.stringify({meta: !!window.VIDEO_META, scenes: typeof window.SceneCore, cfg: typeof window.SCENE_CONFIG, tl: typeof window.TIMELINE})',
65
+ returnByValue: true,
66
+ });
67
+ console.log('page state:', state.result.value);
68
+ console.log('\n--- console / errors ---');
69
+ console.log(logs.length ? logs.join('\n') : '(nothing captured)');
70
+ ws.close();
71
+ } catch (e) {
72
+ console.error('[debug error]', e.message);
73
+ } finally {
74
+ proc.kill();
75
+ }
@@ -0,0 +1,30 @@
1
+ // env.mjs — fail loud on missing prerequisites, instead of a raw ENOENT or a
2
+ // cryptic WebSocket error. Each prerequisite maps to a SETUP.md section.
3
+ //
4
+ // requireNode() — the tools need Node ≥ 22 (global WebSocket / fetch)
5
+ // requireCmd(x) — x must be on PATH (ffmpeg / ffprobe)
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ const NODE_MAJOR = 22;
10
+
11
+ export function requireNode() {
12
+ const major = parseInt(process.versions.node.split('.')[0], 10);
13
+ if (major < NODE_MAJOR) {
14
+ console.error(`[node] Node ${process.versions.node} is too old — ${NODE_MAJOR}+ required (global WebSocket).`);
15
+ console.error(' See SETUP.md (Node ≥ 22).');
16
+ process.exit(1);
17
+ }
18
+ }
19
+
20
+ export function requireCmd(cmd) {
21
+ // spawnSync reports `error` only when the binary could not be launched (ENOENT
22
+ // = not on PATH). A non-zero exit still means it exists, so that is not an error
23
+ // here — `-version` exists precisely because some builds reject `--version`.
24
+ const r = spawnSync(cmd, ['-version'], { stdio: 'ignore' });
25
+ if (r.error) {
26
+ console.error(`[setup] ${cmd} not found on PATH.`);
27
+ console.error(' See SETUP.md (ffmpeg + ffprobe).');
28
+ process.exit(1);
29
+ }
30
+ }