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