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,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
publish.mjs — /publish stage (stage 6): chapters and captions.
|
|
4
|
+
|
|
5
|
+
The agent writes out/<name>/<NN>/publish.json (chapters[] + blurb + youtubeTitle);
|
|
6
|
+
this tool resolves each chapter's timecode from the measured windows, generates the
|
|
7
|
+
SRT from narration/<key>.src, checks YouTube's chapter rules and title limits, and
|
|
8
|
+
writes the three deliverables into the video's destination folder
|
|
9
|
+
(`--dest/<NN>-<slug>/`) beside the MP4:
|
|
10
|
+
|
|
11
|
+
title.txt — the upload title, one line, ≤100 chars
|
|
12
|
+
description.md — the blurb, then the chapter block, paste-ready plain text
|
|
13
|
+
captions.srt — one cue per segment, verbatim spoken text
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
node tools/publish.mjs --work out/<name>/<NN> \
|
|
17
|
+
[--script out/<name>/scripts/<NN>-*.json] \
|
|
18
|
+
[--dest <destination>]
|
|
19
|
+
========================================================================== */
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { join, resolve, dirname, basename } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { videoDir } from './naming.mjs';
|
|
25
|
+
|
|
26
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
const flag = (n, d) => {
|
|
30
|
+
const i = argv.indexOf('--' + n);
|
|
31
|
+
return i === -1 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const WORK = flag('work');
|
|
35
|
+
const SCRIPT = flag('script', null);
|
|
36
|
+
const DEST = flag('dest', null);
|
|
37
|
+
|
|
38
|
+
if (!WORK) {
|
|
39
|
+
console.error('usage: node tools/publish.mjs --work <work-dir> [--script <script.json>] [--dest <dir>]');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const workDir = resolve(WORK);
|
|
44
|
+
const timelinePath = join(workDir, 'timeline.json');
|
|
45
|
+
const publishPath = join(workDir, 'publish.json');
|
|
46
|
+
if (!existsSync(timelinePath)) { console.error(`no timeline at ${timelinePath} — run build-audio first`); process.exit(1); }
|
|
47
|
+
if (!existsSync(publishPath)) { console.error(`no publish.json at ${publishPath} — write it first (chapters[] + blurb)`); process.exit(1); }
|
|
48
|
+
|
|
49
|
+
const tl = JSON.parse(readFileSync(timelinePath, 'utf8'));
|
|
50
|
+
const pub = JSON.parse(readFileSync(publishPath, 'utf8'));
|
|
51
|
+
|
|
52
|
+
/* Read the script once, up front, so the `--dest` copy and the cross-check below
|
|
53
|
+
share one parse and can derive the per-video folder from `videoIndex` + title. */
|
|
54
|
+
let script = null;
|
|
55
|
+
if (SCRIPT) {
|
|
56
|
+
const scriptPath = resolve(SCRIPT);
|
|
57
|
+
if (existsSync(scriptPath)) script = JSON.parse(readFileSync(scriptPath, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
const windows = tl.windows || {};
|
|
60
|
+
const segments = tl.segments || [];
|
|
61
|
+
const errors = [];
|
|
62
|
+
const warnings = [];
|
|
63
|
+
|
|
64
|
+
/* ---- formatting ---- */
|
|
65
|
+
const pad2 = n => String(n).padStart(2, '0');
|
|
66
|
+
const pad3 = n => String(n).padStart(3, '0');
|
|
67
|
+
const srtTs = sec => {
|
|
68
|
+
const ms = Math.round(sec * 1000);
|
|
69
|
+
const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000), s = Math.floor((ms % 60000) / 1000), milli = ms % 1000;
|
|
70
|
+
return `${pad2(h)}:${pad2(m)}:${pad2(s)},${pad3(milli)}`;
|
|
71
|
+
};
|
|
72
|
+
const chapterTs = sec => {
|
|
73
|
+
const s = Math.round(sec);
|
|
74
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
|
|
75
|
+
return h > 0 ? `${h}:${pad2(m)}:${pad2(ss)}` : `${m}:${pad2(ss)}`;
|
|
76
|
+
};
|
|
77
|
+
const normTitle = t => String(t).toLowerCase()
|
|
78
|
+
.replace(/^(the|a|an)\s+/, '')
|
|
79
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
80
|
+
.trim().replace(/\s+/g, ' ');
|
|
81
|
+
|
|
82
|
+
/* ---- chapters: resolve + validate ---- */
|
|
83
|
+
const resolved = [];
|
|
84
|
+
const chapters = pub.chapters;
|
|
85
|
+
if (!Array.isArray(chapters) || chapters.length === 0) {
|
|
86
|
+
errors.push('publish.json must contain a non-empty "chapters" array');
|
|
87
|
+
} else {
|
|
88
|
+
if (chapters.length < 3) errors.push(`need at least 3 chapters (got ${chapters.length}) — YouTube ignores fewer`);
|
|
89
|
+
|
|
90
|
+
chapters.forEach((ch, i) => {
|
|
91
|
+
const at = `chapter ${i + 1}`;
|
|
92
|
+
const keys = ch && ch.segments;
|
|
93
|
+
const title = ch && ch.title;
|
|
94
|
+
if (!Array.isArray(keys) || keys.length === 0) { errors.push(`${at}: "segments" must be a non-empty array of segment keys`); return; }
|
|
95
|
+
keys.forEach(k => { if (!windows[k]) errors.push(`${at}: segment key "${k}" has no timeline window`); });
|
|
96
|
+
const t = title == null ? '' : String(title).trim();
|
|
97
|
+
if (!t) errors.push(`${at}: missing title`);
|
|
98
|
+
else if (t.length > 40) errors.push(`${at}: title is ${t.length} chars (max 40)`);
|
|
99
|
+
if (keys[0] && windows[keys[0]]) resolved.push({ i, title: t, start: windows[keys[0]].start });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (resolved.length) {
|
|
103
|
+
if (chapterTs(resolved[0].start) !== '0:00') {
|
|
104
|
+
errors.push(`first chapter must start at 0:00 (got ${chapterTs(resolved[0].start)}) — anchor it to the first segment`);
|
|
105
|
+
}
|
|
106
|
+
for (let i = 1; i < resolved.length; i++) {
|
|
107
|
+
if (resolved[i].start <= resolved[i - 1].start) errors.push(`chapter ${resolved[i].i + 1} starts at ${chapterTs(resolved[i].start)}, not after chapter ${resolved[i - 1].i + 1}`);
|
|
108
|
+
}
|
|
109
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
110
|
+
const end = i + 1 < resolved.length ? resolved[i + 1].start : tl.total;
|
|
111
|
+
if (end - resolved[i].start < 10) errors.push(`chapter ${resolved[i].i + 1} ("${resolved[i].title}") is ${(end - resolved[i].start).toFixed(1)}s — each chapter must be ≥10s`);
|
|
112
|
+
}
|
|
113
|
+
const seen = new Map();
|
|
114
|
+
resolved.forEach(r => {
|
|
115
|
+
const n = normTitle(r.title);
|
|
116
|
+
if (!n) return;
|
|
117
|
+
if (seen.has(n)) errors.push(`chapters ${seen.get(n) + 1} and ${r.i + 1}: duplicate/near-duplicate title "${r.title}"`);
|
|
118
|
+
else seen.set(n, r.i);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ---- youtubeTitle: the one line a viewer reads before deciding ---- */
|
|
124
|
+
/* `title` is structural: set at stage 2, it names the folder and must never move.
|
|
125
|
+
`youtubeTitle` is what YouTube actually shows, and it can only be written once the
|
|
126
|
+
video exists — which is here. title.txt is also the file `upload_yt_video` reads out
|
|
127
|
+
of the video's folder, so a missing one is an incomplete upload, not a cosmetic gap. */
|
|
128
|
+
const scriptTitle = script && script.title ? String(script.title) : '';
|
|
129
|
+
const rawTitle = pub.youtubeTitle == null ? '' : String(pub.youtubeTitle);
|
|
130
|
+
let ytTitle = rawTitle.replace(/\s+/g, ' ').trim();
|
|
131
|
+
|
|
132
|
+
if (!ytTitle) {
|
|
133
|
+
const fallback = scriptTitle.replace(/\s+/g, ' ').trim();
|
|
134
|
+
if (fallback) {
|
|
135
|
+
ytTitle = fallback;
|
|
136
|
+
warnings.push(`no "youtubeTitle" in publish.json — title.txt fell back to the script title "${fallback}". Author one: it is the only line a viewer reads before deciding to click.`);
|
|
137
|
+
} else {
|
|
138
|
+
errors.push('no "youtubeTitle" in publish.json, and no script title (pass --script) to fall back to');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (ytTitle) {
|
|
143
|
+
if (/[\r\n]/.test(rawTitle)) errors.push('"youtubeTitle" contains a line break — title.txt must be a single line');
|
|
144
|
+
if (ytTitle.length > 100) errors.push(`"youtubeTitle" is ${ytTitle.length} chars — YouTube's hard limit is 100`);
|
|
145
|
+
else if (ytTitle.length > 70) warnings.push(`"youtubeTitle" is ${ytTitle.length} chars — past ~70 it truncates in mobile listings`);
|
|
146
|
+
const letters = ytTitle.replace(/[^A-Za-z]/g, '');
|
|
147
|
+
if (letters.length >= 8 && letters === letters.toUpperCase()) warnings.push('"youtubeTitle" is all uppercase — reads as a gimmick and truncates badly');
|
|
148
|
+
if (/[!?]{2,}/.test(ytTitle)) warnings.push('"youtubeTitle" stacks "!" or "?" — the same shape as the clickbait it competes with');
|
|
149
|
+
|
|
150
|
+
/* A series whose parts are titled near-identically buries itself. Siblings are read
|
|
151
|
+
opportunistically: a half-written neighbour is not this video's problem. */
|
|
152
|
+
try {
|
|
153
|
+
const parent = dirname(workDir);
|
|
154
|
+
const mine = normTitle(ytTitle);
|
|
155
|
+
for (const e of readdirSync(parent, { withFileTypes: true })) {
|
|
156
|
+
if (!e.isDirectory() || join(parent, e.name) === workDir) continue;
|
|
157
|
+
const sib = join(parent, e.name, 'publish.json');
|
|
158
|
+
if (!existsSync(sib)) continue;
|
|
159
|
+
let other = '';
|
|
160
|
+
try { other = String(JSON.parse(readFileSync(sib, 'utf8')).youtubeTitle || ''); } catch { continue; }
|
|
161
|
+
if (other && normTitle(other) === mine) warnings.push(`"youtubeTitle" matches video ${e.name}'s ("${other}") — each part of a series needs its own reason to be clicked`);
|
|
162
|
+
}
|
|
163
|
+
} catch { /* nothing to compare against */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ---- captions: one cue per segment, verbatim from the audio ---- */
|
|
167
|
+
const cues = [];
|
|
168
|
+
segments.forEach(sg => {
|
|
169
|
+
const srcPath = join(workDir, 'narration', `${sg.key}.src`);
|
|
170
|
+
if (!existsSync(srcPath)) { errors.push(`segment "${sg.key}": missing narration/${sg.key}.src — cannot build its caption`); return; }
|
|
171
|
+
const text = readFileSync(srcPath, 'utf8').replace(/\s+$/, '');
|
|
172
|
+
cues.push({ key: sg.key, start: sg.start, end: sg.end, text });
|
|
173
|
+
});
|
|
174
|
+
for (let i = 1; i < cues.length; i++) {
|
|
175
|
+
if (cues[i].start < cues[i - 1].end) errors.push(`captions overlap at "${cues[i].key}"`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---- optional cross-check against the script ---- */
|
|
179
|
+
if (SCRIPT) {
|
|
180
|
+
if (!script) warnings.push(`--script not found or unreadable: ${SCRIPT}`);
|
|
181
|
+
else {
|
|
182
|
+
const scriptKeys = new Set((script.segments || []).map(s => s.key));
|
|
183
|
+
segments.forEach(s => { if (!scriptKeys.has(s.key)) warnings.push(`timeline has segment "${s.key}" not present in the script`); });
|
|
184
|
+
scriptKeys.forEach(k => { if (!windows[k]) warnings.push(`script has segment "${k}" with no timeline window`); });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---- report ---- */
|
|
189
|
+
const label = String(WORK).split('/').pop() || 'publish';
|
|
190
|
+
console.log(`\n${label}`);
|
|
191
|
+
console.log('─'.repeat(Math.min(60, label.length + 20)));
|
|
192
|
+
warnings.forEach(w => console.log(` ⚠ ${w}`));
|
|
193
|
+
if (errors.length) {
|
|
194
|
+
console.log(`\n ✗ ${errors.length} error(s):`);
|
|
195
|
+
errors.forEach(e => console.log(` - ${e}`));
|
|
196
|
+
process.exit(2);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ---- emit ---- */
|
|
200
|
+
const descLines = [];
|
|
201
|
+
if (pub.blurb && String(pub.blurb).trim()) descLines.push(String(pub.blurb).trim(), '');
|
|
202
|
+
resolved.forEach(r => descLines.push(`${chapterTs(r.start)} ${r.title}`));
|
|
203
|
+
const description = descLines.join('\n') + '\n';
|
|
204
|
+
|
|
205
|
+
const srt = cues.map((c, i) => `${i + 1}\n${srtTs(c.start)} --> ${srtTs(c.end)}\n${c.text}\n`).join('\n');
|
|
206
|
+
|
|
207
|
+
const titleOut = join(workDir, 'title.txt');
|
|
208
|
+
const descOut = join(workDir, 'description.md');
|
|
209
|
+
const srtOut = join(workDir, 'captions.srt');
|
|
210
|
+
writeFileSync(titleOut, `${ytTitle}\n`);
|
|
211
|
+
writeFileSync(descOut, description);
|
|
212
|
+
writeFileSync(srtOut, srt);
|
|
213
|
+
|
|
214
|
+
console.log(` title : ${ytTitle.length} chars — "${ytTitle}"`);
|
|
215
|
+
console.log(` chapters : ${resolved.length} (${chapterTs(resolved[0].start)} → ${chapterTs(resolved[resolved.length - 1].start)})`);
|
|
216
|
+
console.log(` captions : ${cues.length} cues (${(tl.total).toFixed(1)}s of narration)`);
|
|
217
|
+
console.log(`\n[out] ${titleOut}`);
|
|
218
|
+
console.log(`[out] ${descOut}`);
|
|
219
|
+
console.log(`[out] ${srtOut}`);
|
|
220
|
+
|
|
221
|
+
if (DEST) {
|
|
222
|
+
/* One folder per video: <dest>/<NN>-<slug>/. The folder, not the filename, is the
|
|
223
|
+
namespace, so the fixed description.md / captions.srt can never collide. */
|
|
224
|
+
const dir = join(resolve(DEST), videoDir(script, basename(workDir)));
|
|
225
|
+
mkdirSync(dir, { recursive: true });
|
|
226
|
+
copyFileSync(titleOut, join(dir, 'title.txt'));
|
|
227
|
+
copyFileSync(descOut, join(dir, 'description.md'));
|
|
228
|
+
copyFileSync(srtOut, join(dir, 'captions.srt'));
|
|
229
|
+
console.log(`[dest] ${dir}`);
|
|
230
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Headless frame renderer + encoder. No npm dependencies.
|
|
2
|
+
//
|
|
3
|
+
// node tools/render.mjs --shots 5,12,22,30,45,60,72,82,90 # quick visual QA
|
|
4
|
+
// node tools/render.mjs # full render + encode + mux
|
|
5
|
+
// node tools/render.mjs --fps 24 --keep-frames # options
|
|
6
|
+
//
|
|
7
|
+
// Pipeline: Chromium (CDP) -> PNG sequence -> ffmpeg H.264 -> mux narration.
|
|
8
|
+
|
|
9
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
10
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, renameSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { chromiumBinOrExit } from './chromium.mjs';
|
|
14
|
+
import { runFrames } from './frame-dedup.mjs';
|
|
15
|
+
import { requireNode, requireCmd } from './env.mjs';
|
|
16
|
+
|
|
17
|
+
requireNode();
|
|
18
|
+
|
|
19
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
|
|
21
|
+
/* ---------- args ---------- */
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const flag = (name, def = null) => {
|
|
24
|
+
const i = argv.indexOf('--' + name);
|
|
25
|
+
return i === -1 ? def : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
|
|
26
|
+
};
|
|
27
|
+
const SHOTS = flag('shots');
|
|
28
|
+
const FPS = parseInt(flag('fps', '30'), 10);
|
|
29
|
+
const KEEP_FRAMES = !!flag('keep-frames', false);
|
|
30
|
+
const PORT = parseInt(flag('port', '9333'), 10);
|
|
31
|
+
const QUALITY = flag('crf', '18');
|
|
32
|
+
// Which scene file to render, and where stills go. Lets us render the hand-written
|
|
33
|
+
// scene and the DSL-driven scene through an identical pipeline for comparison.
|
|
34
|
+
const SCENE = String(flag("scene", "src/scene-diagram.html?scene=srp-split"));
|
|
35
|
+
const SHOT_DIR = flag('shot-dir');
|
|
36
|
+
const OUT_NAME = String(flag('name', 'video'));
|
|
37
|
+
const NARR = String(flag('narration', 'out/narration.wav'));
|
|
38
|
+
// "t0,t1" — render only this slice of the timeline, for per-segment assembly
|
|
39
|
+
const RANGE = flag('range');
|
|
40
|
+
const OUTDIR = String(flag('outdir', 'out'));
|
|
41
|
+
// the per-video work dir (timeline.js + scenes/ + manifest.json live here)
|
|
42
|
+
const WORK = String(flag('work', ''));
|
|
43
|
+
|
|
44
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
45
|
+
|
|
46
|
+
/* ---------- CDP client over Node's built-in WebSocket ---------- */
|
|
47
|
+
async function connect(port) {
|
|
48
|
+
let target = null;
|
|
49
|
+
for (let i = 0; i < 60; i++) {
|
|
50
|
+
await sleep(250);
|
|
51
|
+
try {
|
|
52
|
+
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
|
53
|
+
target = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
54
|
+
if (target) break;
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
if (!target) throw new Error('Chromium did not expose a page target');
|
|
58
|
+
|
|
59
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
60
|
+
let id = 0;
|
|
61
|
+
const pending = new Map();
|
|
62
|
+
ws.addEventListener('message', e => {
|
|
63
|
+
const m = JSON.parse(e.data);
|
|
64
|
+
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
|
|
65
|
+
});
|
|
66
|
+
await new Promise((res, rej) => {
|
|
67
|
+
ws.addEventListener('open', res, { once: true });
|
|
68
|
+
ws.addEventListener('error', rej, { once: true });
|
|
69
|
+
});
|
|
70
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
71
|
+
const myId = ++id;
|
|
72
|
+
pending.set(myId, m => m.error ? reject(new Error(method + ': ' + JSON.stringify(m.error))) : resolve(m.result));
|
|
73
|
+
ws.send(JSON.stringify({ id: myId, method, params }));
|
|
74
|
+
});
|
|
75
|
+
return { send, close: () => { try { ws.close(); } catch {} } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ---------- main ---------- */
|
|
79
|
+
const BIN = chromiumBinOrExit('render');
|
|
80
|
+
const [sceneFile, sceneQuery] = SCENE.split('?');
|
|
81
|
+
let urlQuery = sceneQuery || '';
|
|
82
|
+
if (WORK) { const workUrl = pathToFileURL(resolve(WORK)).href; urlQuery = urlQuery ? urlQuery + '&work=' + encodeURIComponent(workUrl) : 'work=' + encodeURIComponent(workUrl); }
|
|
83
|
+
const sceneUrl = 'file://' + join(ROOT, sceneFile) + (urlQuery ? '?' + urlQuery : '');
|
|
84
|
+
console.log('[chromium]', BIN);
|
|
85
|
+
|
|
86
|
+
const proc = spawn(BIN, [
|
|
87
|
+
'--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
88
|
+
'--force-device-scale-factor=1', '--allow-file-access-from-files',
|
|
89
|
+
'--disable-lcd-text', '--font-render-hinting=none',
|
|
90
|
+
`--remote-debugging-port=${PORT}`,
|
|
91
|
+
'--window-size=1920,1080',
|
|
92
|
+
sceneUrl,
|
|
93
|
+
], { stdio: 'ignore' });
|
|
94
|
+
|
|
95
|
+
let exitCode = 0;
|
|
96
|
+
try {
|
|
97
|
+
const cdp = await connect(PORT);
|
|
98
|
+
await cdp.send('Page.enable');
|
|
99
|
+
await cdp.send('Runtime.enable');
|
|
100
|
+
await cdp.send('Emulation.setDeviceMetricsOverride',
|
|
101
|
+
{ width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false });
|
|
102
|
+
|
|
103
|
+
// wait for the scene to finish building
|
|
104
|
+
const meta = await (async () => {
|
|
105
|
+
for (let i = 0; i < 80; i++) {
|
|
106
|
+
const r = await cdp.send('Runtime.evaluate', {
|
|
107
|
+
expression: 'JSON.stringify(window.VIDEO_META || null)', returnByValue: true,
|
|
108
|
+
});
|
|
109
|
+
const v = r.result.value;
|
|
110
|
+
if (v && v !== 'null') return JSON.parse(v);
|
|
111
|
+
await sleep(150);
|
|
112
|
+
}
|
|
113
|
+
throw new Error('scene.html never exposed window.VIDEO_META');
|
|
114
|
+
})();
|
|
115
|
+
console.log(`[scene] ${meta.width}x${meta.height} @ ${meta.fps}fps, ${meta.total}s`);
|
|
116
|
+
|
|
117
|
+
const screenshot = async () => Buffer.from(
|
|
118
|
+
(await cdp.send('Page.captureScreenshot',
|
|
119
|
+
{ format: 'png', fromSurface: true, captureBeyondViewport: false })).data, 'base64');
|
|
120
|
+
const shoot = async (t) => {
|
|
121
|
+
await cdp.send('Runtime.evaluate', { expression: `window.renderAt(${t})`, returnByValue: true });
|
|
122
|
+
return screenshot();
|
|
123
|
+
};
|
|
124
|
+
const evaluate = async (expr) =>
|
|
125
|
+
(await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value;
|
|
126
|
+
|
|
127
|
+
/* ---- QA mode: a handful of stills ---- */
|
|
128
|
+
if (SHOTS) {
|
|
129
|
+
const dir = resolve(SHOT_DIR || (WORK ? join(WORK, 'preview') : 'preview'));
|
|
130
|
+
mkdirSync(dir, { recursive: true });
|
|
131
|
+
const times = String(SHOTS).split(',').map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
|
|
132
|
+
for (const t of times) {
|
|
133
|
+
const buf = await shoot(t);
|
|
134
|
+
const p = join(dir, `t${String(t).padStart(6, '0')}.png`);
|
|
135
|
+
writeFileSync(p, buf);
|
|
136
|
+
console.log(`[shot] t=${t}s -> ${(buf.length/1024).toFixed(0)}KB`);
|
|
137
|
+
}
|
|
138
|
+
cdp.close();
|
|
139
|
+
} else {
|
|
140
|
+
/* ---- full render ---- */
|
|
141
|
+
const dir = resolve(WORK ? join(WORK, 'frames') : 'frames');
|
|
142
|
+
/* Wipe, never inherit. frame_%05d.png is numbered from 1 on every run, so leftover
|
|
143
|
+
frames from an earlier render in this dir would otherwise be encoded into this
|
|
144
|
+
one — the frames dir is derived, so nothing here is worth keeping. */
|
|
145
|
+
rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
const slice = RANGE ? String(RANGE).split(',').map(Number) : [0, meta.total];
|
|
148
|
+
const tStart = slice[0], tEnd = slice[1] == null ? meta.total : slice[1];
|
|
149
|
+
const total = Math.max(1, Math.round((tEnd - tStart) * FPS));
|
|
150
|
+
console.log(`[render] ${total} frames @ ${FPS}fps (t ${tStart}s -> ${tEnd}s)`);
|
|
151
|
+
/* Capture only the frames that actually change; repeat frames are hardlinked from
|
|
152
|
+
their predecessor. See tools/frame-dedup.mjs for the probe, the animation gate,
|
|
153
|
+
and the sampled audit that makes a wrong answer impossible to ship silently. */
|
|
154
|
+
const stats = await runFrames({ dir, fps: FPS, tStart, tEnd, evaluate, screenshot });
|
|
155
|
+
console.log(`[dedup] ${stats.screenshots}/${stats.total} screenshots · ` +
|
|
156
|
+
`${(100 * stats.reused / stats.total).toFixed(1)}% reused · ${stats.distinct} distinct states · ` +
|
|
157
|
+
`audits ${stats.audits} (${stats.mismatches} mismatched) · capture ${stats.captureSec.toFixed(1)}s`);
|
|
158
|
+
cdp.close();
|
|
159
|
+
|
|
160
|
+
/* ---- encode ---- */
|
|
161
|
+
requireCmd('ffmpeg');
|
|
162
|
+
const outDir = resolve(OUTDIR); // cwd-relative, so an absolute --outdir is honoured
|
|
163
|
+
mkdirSync(outDir, { recursive: true });
|
|
164
|
+
const silent = join(outDir, 'video-silent.mp4');
|
|
165
|
+
console.log('[ffmpeg] encoding H.264…');
|
|
166
|
+
execFileSync('ffmpeg', ['-y', '-v', 'error', '-framerate', String(FPS),
|
|
167
|
+
'-i', join(dir, 'frame_%05d.png'),
|
|
168
|
+
'-c:v', 'libx264', '-preset', 'medium', '-crf', String(QUALITY),
|
|
169
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', silent], { stdio: 'inherit' });
|
|
170
|
+
|
|
171
|
+
/* ---- mux narration ---- */
|
|
172
|
+
const narr = resolve(NARR);
|
|
173
|
+
const final = join(outDir, OUT_NAME + '.mp4');
|
|
174
|
+
if (existsSync(narr)) {
|
|
175
|
+
console.log('[ffmpeg] muxing narration…');
|
|
176
|
+
// apad + -shortest: silence extends the audio, then -shortest cuts at the
|
|
177
|
+
// VIDEO. Plain -shortest would instead truncate a 52s scene to 45s of audio.
|
|
178
|
+
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', silent, '-i', narr,
|
|
179
|
+
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-af', 'apad', '-shortest',
|
|
180
|
+
'-movflags', '+faststart', final], { stdio: 'inherit' });
|
|
181
|
+
rmSync(silent, { force: true });
|
|
182
|
+
console.log('[done]', final);
|
|
183
|
+
} else {
|
|
184
|
+
// silent cut (e.g. a review pass before audio exists) — still ship the named output
|
|
185
|
+
rmSync(final, { force: true });
|
|
186
|
+
renameSync(silent, final);
|
|
187
|
+
console.log('[done]', final, '(silent — no narration.wav found)');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Frames are worthless once the MP4 is muxed: fully deterministic, free to
|
|
191
|
+
// regenerate (~6 min CPU, no API calls), and invalidated by any change to the
|
|
192
|
+
// narration timing anyway. The scarce artefact is narration/, not frames.
|
|
193
|
+
if (!KEEP_FRAMES) {
|
|
194
|
+
rmSync(dir, { recursive: true, force: true });
|
|
195
|
+
console.log('[clean] frames removed (use --keep-frames to retain)');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error('\n[error]', err.message);
|
|
200
|
+
exitCode = 1;
|
|
201
|
+
} finally {
|
|
202
|
+
proc.kill();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
process.exit(exitCode);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
resolve-beats.mjs — the gate between "a beat says which line it belongs to"
|
|
4
|
+
and "a beat has a number of seconds".
|
|
5
|
+
|
|
6
|
+
The bug this closes
|
|
7
|
+
-------------------
|
|
8
|
+
A scene used to declare `at: 6.6` — a hand-guessed second. Nothing measured it,
|
|
9
|
+
so a beat could fire six seconds early and every check still passed. On the
|
|
10
|
+
module-07 pilot the April reveal fired at 6.60s while the words "In April"
|
|
11
|
+
start at 12.60s.
|
|
12
|
+
|
|
13
|
+
The contract now
|
|
14
|
+
----------------
|
|
15
|
+
A scene declares WHICH LINE of the narration a beat belongs to:
|
|
16
|
+
|
|
17
|
+
window.SCENE_BEATS = {
|
|
18
|
+
beats: [
|
|
19
|
+
{ id: 'march', line: 1, min: 5 },
|
|
20
|
+
{ id: 'april', line: 2, min: 5 },
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
and `tools/align.mjs` has measured every line's span. This tool resolves each
|
|
25
|
+
`line` to seconds, writes `beats.json`, and REFUSES to pass when:
|
|
26
|
+
|
|
27
|
+
• a beat carries a raw `at` and no `line` (a hand-guessed time)
|
|
28
|
+
• a `line` index does not exist in lines.json (a stale or invented line)
|
|
29
|
+
• lines are referenced out of order (a beat before its own line)
|
|
30
|
+
• two beats share an id (ambiguous resolution)
|
|
31
|
+
• lines.json is missing or older than the WAV (stale alignment)
|
|
32
|
+
• a narration line has no beat at all (narration with no visual)
|
|
33
|
+
|
|
34
|
+
Beat times are written in SCENE time — relative to the segment's render window,
|
|
35
|
+
so `at = line.start + lead` (lead is measured, not assumed).
|
|
36
|
+
|
|
37
|
+
Usage
|
|
38
|
+
node tools/resolve-beats.mjs --work out/<name>/<NN> [--allow-uncovered]
|
|
39
|
+
node tools/resolve-beats.mjs --work out/<name>/<NN> --print
|
|
40
|
+
========================================================================== */
|
|
41
|
+
|
|
42
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
43
|
+
import { join, resolve, basename } from 'node:path';
|
|
44
|
+
import { pathToFileURL } from 'node:url';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
46
|
+
|
|
47
|
+
const flag = (n, d) => {
|
|
48
|
+
const i = process.argv.indexOf('--' + n);
|
|
49
|
+
if (i === -1) return d;
|
|
50
|
+
const v = process.argv[i + 1];
|
|
51
|
+
return v && !v.startsWith('--') ? v : true;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const WORK = flag('work');
|
|
55
|
+
const SCRIPT = flag('script', null); // optional: verify lines.json still matches the narration
|
|
56
|
+
const ALLOW_UNCOVERED = !!flag('allow-uncovered', false);
|
|
57
|
+
const PRINT = !!flag('print', false);
|
|
58
|
+
|
|
59
|
+
if (!WORK) {
|
|
60
|
+
console.error('usage: node tools/resolve-beats.mjs --work <work-dir> [--allow-uncovered] [--print]');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const workDir = resolve(WORK);
|
|
64
|
+
const linesPath = join(workDir, 'lines.json');
|
|
65
|
+
const scenesDir = join(workDir, 'scenes');
|
|
66
|
+
|
|
67
|
+
const rel = p => p.replace(process.cwd() + '/', '');
|
|
68
|
+
const errors = [];
|
|
69
|
+
const warnings = [];
|
|
70
|
+
|
|
71
|
+
if (!existsSync(linesPath)) {
|
|
72
|
+
console.error(`no lines.json at ${rel(linesPath)} — run tools/align.mjs first`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
if (!existsSync(scenesDir)) {
|
|
76
|
+
console.error(`no scenes/ at ${rel(scenesDir)} — author the scenes first (stage 5)`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const linesDoc = JSON.parse(readFileSync(linesPath, 'utf8'));
|
|
80
|
+
const segLines = linesDoc.segments || {};
|
|
81
|
+
|
|
82
|
+
/* THE LOOP GUARD. The audio stage can be re-invoked from the scene stage to resolve a
|
|
83
|
+
shortfall. A `pad` only widens the window (line times stay valid), but "extend the
|
|
84
|
+
narration" changes the WAV — and then every line time in lines.json describes audio
|
|
85
|
+
that no longer exists. Nothing else would notice, so compare the text hash align.mjs
|
|
86
|
+
recorded against the script as it stands now. */
|
|
87
|
+
const scriptByKey = (() => {
|
|
88
|
+
if (!SCRIPT) return null;
|
|
89
|
+
if (!existsSync(String(SCRIPT))) { errors.push(`--script not found: ${SCRIPT}`); return null; }
|
|
90
|
+
const doc = JSON.parse(readFileSync(String(SCRIPT), 'utf8'));
|
|
91
|
+
const sha1 = t => createHash('sha1').update(t).digest('hex');
|
|
92
|
+
return new Map((doc.segments || []).map(sg => [sg.key, sha1(sg.text)]));
|
|
93
|
+
})();
|
|
94
|
+
if (scriptByKey) {
|
|
95
|
+
for (const [segKey, seg] of Object.entries(segLines)) {
|
|
96
|
+
const want = scriptByKey.get(segKey);
|
|
97
|
+
if (want && seg.textHash && want !== seg.textHash) {
|
|
98
|
+
errors.push(`${segKey}: narration text changed since align ran — re-run tools/align.mjs (its word cache makes this free unless the audio changed)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Read a scene's declared beats by EXECUTING it with a stubbed `window`, exactly
|
|
104
|
+
as pacing-check and the scene host do. Regex-parsing the literal would reject
|
|
105
|
+
hand-written JS object syntax (unquoted keys), which is a legitimate way to
|
|
106
|
+
author a scene. */
|
|
107
|
+
async function readBeats(path) {
|
|
108
|
+
const prev = global.window;
|
|
109
|
+
global.window = {};
|
|
110
|
+
try {
|
|
111
|
+
await import(pathToFileURL(path).href + '?v=' + Date.now());
|
|
112
|
+
const declared = global.window.SCENE_BEATS;
|
|
113
|
+
if (!declared) return { beats: null };
|
|
114
|
+
const beats = Array.isArray(declared) ? declared : declared.beats;
|
|
115
|
+
return { beats: Array.isArray(beats) ? beats : null, legacyArray: Array.isArray(declared) };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return { beats: null, threw: e.message };
|
|
118
|
+
} finally {
|
|
119
|
+
global.window = prev;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sceneFiles = readdirSync(scenesDir).filter(f => f.endsWith('.js')).sort();
|
|
124
|
+
if (!sceneFiles.length) errors.push(`no scene files in ${rel(scenesDir)}`);
|
|
125
|
+
|
|
126
|
+
const resolved = {};
|
|
127
|
+
const coveredLines = {}; // segKey -> Set of covered line indices
|
|
128
|
+
|
|
129
|
+
for (const file of sceneFiles) {
|
|
130
|
+
const segKey = basename(file, '.js');
|
|
131
|
+
const { beats, legacyArray, threw } = await readBeats(join(scenesDir, file));
|
|
132
|
+
const seg = segLines[segKey];
|
|
133
|
+
|
|
134
|
+
if (!seg) { errors.push(`${segKey}: scene exists but lines.json has no entry — re-run align.mjs`); continue; }
|
|
135
|
+
if (!beats) {
|
|
136
|
+
errors.push(`${segKey}: declares no window.SCENE_BEATS${threw ? ` (scene threw: ${threw})` : ''}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (legacyArray) {
|
|
140
|
+
warnings.push(`${segKey}: SCENE_BEATS is a bare array — prefer { beats: [...] } so `+
|
|
141
|
+
`non-beat metadata can live alongside it`);
|
|
142
|
+
}
|
|
143
|
+
if (!beats.length) { errors.push(`${segKey}: SCENE_BEATS is empty`); continue; }
|
|
144
|
+
|
|
145
|
+
const lead = seg.lead ?? 0;
|
|
146
|
+
const seenIds = new Set();
|
|
147
|
+
const out = [];
|
|
148
|
+
let lastLine = -1;
|
|
149
|
+
coveredLines[segKey] = new Set();
|
|
150
|
+
|
|
151
|
+
for (const b of beats) {
|
|
152
|
+
const id = b && b.id;
|
|
153
|
+
if (!id) { errors.push(`${segKey}: a beat has no id`); continue; }
|
|
154
|
+
if (seenIds.has(id)) { errors.push(`${segKey}: duplicate beat id "${id}"`); continue; }
|
|
155
|
+
seenIds.add(id);
|
|
156
|
+
|
|
157
|
+
if (b.at != null && b.line == null) {
|
|
158
|
+
errors.push(`${segKey} / ${id}: declares a raw \`at: ${b.at}\` and no \`line\` — a guessed second is exactly the defect this stage removes. Bind the beat to a narration line instead.`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
/* a beat names ONE line, or a RANGE it narrates over — a closing caption
|
|
162
|
+
legitimately holds while the last two or three sentences play, so the range
|
|
163
|
+
form lets coverage be satisfied without inventing a visual per sentence. */
|
|
164
|
+
const range = Array.isArray(b.lines) ? b.lines : (b.line != null ? [b.line, b.line] : null);
|
|
165
|
+
if (!range) { errors.push(`${segKey} / ${id}: no \`line\` (or \`lines: [from,to]\`) — every beat must name the narration it illustrates`); continue; }
|
|
166
|
+
if (range.length !== 2 || !range.every(Number.isInteger)) {
|
|
167
|
+
errors.push(`${segKey} / ${id}: \`lines\` must be [from, to] integers, got ${JSON.stringify(b.lines)}`); continue;
|
|
168
|
+
}
|
|
169
|
+
const [lFrom, lTo] = range;
|
|
170
|
+
if (lFrom < 0 || lTo >= seg.lines.length || lTo < lFrom) {
|
|
171
|
+
errors.push(`${segKey} / ${id}: lines ${lFrom}…${lTo} do not exist (segment has ${seg.lines.length} lines: 0…${seg.lines.length - 1})`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (lFrom < lastLine) warnings.push(`${segKey} / ${id}: line ${lFrom} is before the previous beat's line ${lastLine} — visual order disagrees with narration order`);
|
|
175
|
+
lastLine = Math.max(lastLine, lTo);
|
|
176
|
+
|
|
177
|
+
const L = seg.lines[lFrom];
|
|
178
|
+
/* A resolved number baked into the scene must still agree with lines.json —
|
|
179
|
+
otherwise the audio moved and the scene kept the old time. */
|
|
180
|
+
const want = +(L.start + lead).toFixed(3);
|
|
181
|
+
if (b.at != null && Math.abs(b.at - want) > 0.06) {
|
|
182
|
+
errors.push(`${segKey} / ${id}: baked \`at: ${b.at}\` disagrees with line ${b.line}'s measured onset ${want}s — stale resolve, regenerate the scene`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (let li = lFrom; li <= lTo; li++) coveredLines[segKey].add(li);
|
|
186
|
+
out.push({
|
|
187
|
+
id,
|
|
188
|
+
line: lFrom,
|
|
189
|
+
lines: [lFrom, lTo],
|
|
190
|
+
at: +(L.start + lead).toFixed(3), // scene time
|
|
191
|
+
lineStart: L.start,
|
|
192
|
+
lineEnd: L.end,
|
|
193
|
+
min: b.min ?? 0,
|
|
194
|
+
hold: !!b.hold,
|
|
195
|
+
text: L.text,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
resolved[segKey] = { lead, total: seg.total, window: seg.window, beats: out };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* coverage: every narration line should have a visual */
|
|
202
|
+
if (!ALLOW_UNCOVERED) {
|
|
203
|
+
for (const [segKey, seg] of Object.entries(segLines)) {
|
|
204
|
+
if (!resolved[segKey]) continue; // no scene yet: reported elsewhere
|
|
205
|
+
const covered = coveredLines[segKey] || new Set();
|
|
206
|
+
seg.lines.forEach(L => {
|
|
207
|
+
if (!covered.has(L.line)) {
|
|
208
|
+
warnings.push(`${segKey}: line ${L.line} has no beat — "${L.text.slice(0, 58)}…" is narrated with nothing changing on screen`);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const outPath = join(workDir, 'beats.json');
|
|
215
|
+
const doc = {
|
|
216
|
+
generatedFrom: { lines: 'lines.json', at: linesDoc.generatedFrom?.at || null, resolver: 'tools/resolve-beats.mjs' },
|
|
217
|
+
note: 'Beat `at` is in SCENE time (relative to the render window): line.start + lead.',
|
|
218
|
+
segments: resolved,
|
|
219
|
+
};
|
|
220
|
+
writeFileSync(outPath, JSON.stringify(doc, null, 1) + '\n');
|
|
221
|
+
|
|
222
|
+
/* report */
|
|
223
|
+
const nBeats = Object.values(resolved).reduce((a, s) => a + s.beats.length, 0);
|
|
224
|
+
if (PRINT) {
|
|
225
|
+
for (const [segKey, s] of Object.entries(resolved)) {
|
|
226
|
+
console.log(`\n${segKey} (lead ${s.lead}s)`);
|
|
227
|
+
s.beats.forEach(b => console.log(` ${b.id.padEnd(14)} line ${String(b.line).padStart(2)} at ${b.at.toFixed(2).padStart(7)}s "${b.text.slice(0, 46)}…"`));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
console.log(`\n[resolve] ${Object.keys(resolved).length} scene(s) · ${nBeats} beat(s)`);
|
|
231
|
+
if (warnings.length) { console.log(`\n ⚠ ${warnings.length} warning(s):`); warnings.forEach(w => console.log(` - ${w}`)); }
|
|
232
|
+
if (errors.length) {
|
|
233
|
+
console.log(`\n ✗ ${errors.length} error(s):`);
|
|
234
|
+
errors.forEach(e => console.log(` - ${e}`));
|
|
235
|
+
process.exitCode = 2;
|
|
236
|
+
} else if (!PRINT) {
|
|
237
|
+
console.log(` ✓ written: ${rel(outPath)}`);
|
|
238
|
+
process.exitCode = 0;
|
|
239
|
+
}
|