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,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
align.mjs — stage 4b: per-word timings, and the LINE spans a scene can bind to.
|
|
4
|
+
|
|
5
|
+
Why this exists
|
|
6
|
+
---------------
|
|
7
|
+
A scene's beats must fire when the narration says the thing the beat shows, and
|
|
8
|
+
the only honest way to know that is to measure it. Measuring at segment
|
|
9
|
+
granularity (build-audio) leaves everything inside a segment to guesswork, and a
|
|
10
|
+
guessed beat can be seconds off while every existing check still passes.
|
|
11
|
+
|
|
12
|
+
Request budget — why this transcribes the WHOLE TRACK by default
|
|
13
|
+
----------------------------------------------------------------
|
|
14
|
+
gemini-3.5-transcribe is capped at 100 requests/day per project per model
|
|
15
|
+
(GenerateRequestsPerDayPerProjectPerModel, per the API's own QuotaFailure
|
|
16
|
+
payload), the Batch API is not supported for it, and the cap cannot be spread
|
|
17
|
+
across keys on one project. Transcribing per segment spent ~30 requests on a
|
|
18
|
+
13-minute video; one pass over narration.wav spends one.
|
|
19
|
+
|
|
20
|
+
The model allows "up to 1 hour per request — up to 30 minutes when ... word-level
|
|
21
|
+
timestamps are enabled", so this file:
|
|
22
|
+
- <= MAX_PASS seconds -> one request over the whole track
|
|
23
|
+
- longer -> the track is cut at SEGMENT boundaries into <= MAX_PASS
|
|
24
|
+
pieces (never mid-sentence), one request each, offsets
|
|
25
|
+
re-based onto the track
|
|
26
|
+
- --per-segment -> the old path, kept as a fallback. Chosen automatically
|
|
27
|
+
when only a few segments are missing, where it is cheaper.
|
|
28
|
+
|
|
29
|
+
Already-cached segments are never re-requested, and a whole-track pass fills only
|
|
30
|
+
the segments that were missing — so a fallback cannot overwrite good measurements.
|
|
31
|
+
|
|
32
|
+
Usage
|
|
33
|
+
node tools/align.mjs --script out/<name>/scripts/NN-<slug>.json \
|
|
34
|
+
--work out/<name>/<NN> [--only <key>] [--force] \
|
|
35
|
+
[--whole-track | --per-segment] \
|
|
36
|
+
[--min-match 0.8] [--sleep 150]
|
|
37
|
+
========================================================================== */
|
|
38
|
+
|
|
39
|
+
import { execFileSync } from 'node:child_process';
|
|
40
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdtempSync, rmSync } from 'node:fs';
|
|
41
|
+
import { createHash } from 'node:crypto';
|
|
42
|
+
import { join, resolve } from 'node:path';
|
|
43
|
+
import { tmpdir } from 'node:os';
|
|
44
|
+
import { transcribeWords, MODEL } from './transcribe-gemini.mjs';
|
|
45
|
+
import { loadKey, describe, storeHint } from './key-source.mjs';
|
|
46
|
+
import { claimWorkDir } from './workdir.mjs';
|
|
47
|
+
import { requireCmd } from './env.mjs';
|
|
48
|
+
|
|
49
|
+
requireCmd('ffmpeg');
|
|
50
|
+
requireCmd('ffprobe');
|
|
51
|
+
|
|
52
|
+
/* Model ceiling is 30 min with word timestamps; keep a margin for rounding. */
|
|
53
|
+
const MAX_PASS = 28 * 60;
|
|
54
|
+
/* Below this many missing segments, per-segment calls are cheaper than a whole pass. */
|
|
55
|
+
const AUTO_WHOLE_TRACK_ABOVE = 3;
|
|
56
|
+
|
|
57
|
+
const flag = (n, d) => {
|
|
58
|
+
const i = process.argv.indexOf('--' + n);
|
|
59
|
+
if (i === -1) return d;
|
|
60
|
+
const v = process.argv[i + 1];
|
|
61
|
+
return v && !v.startsWith('--') ? v : true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const SCRIPT = flag('script');
|
|
65
|
+
const WORK = flag('work');
|
|
66
|
+
const ONLY = flag('only', null);
|
|
67
|
+
const FORCE = !!flag('force', false);
|
|
68
|
+
const FORCE_WHOLE = !!flag('whole-track', false);
|
|
69
|
+
const FORCE_PER_SEG = !!flag('per-segment', false);
|
|
70
|
+
const MIN_MATCH = parseFloat(flag('min-match', '0.8'));
|
|
71
|
+
const SLEEP = parseInt(flag('sleep', '150'), 10);
|
|
72
|
+
|
|
73
|
+
if (!SCRIPT || !WORK) {
|
|
74
|
+
console.error('usage: node tools/align.mjs --script <script.json> --work <work-dir> [--only <key>] [--force] [--whole-track|--per-segment]');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
if (!existsSync(SCRIPT)) { console.error('not found: ' + SCRIPT); process.exit(1); }
|
|
78
|
+
if (FORCE_WHOLE && FORCE_PER_SEG) { console.error('--whole-track and --per-segment are mutually exclusive'); process.exit(1); }
|
|
79
|
+
|
|
80
|
+
const scriptPath = resolve(SCRIPT);
|
|
81
|
+
const workDir = resolve(WORK);
|
|
82
|
+
const script = JSON.parse(readFileSync(scriptPath, 'utf8'));
|
|
83
|
+
|
|
84
|
+
/* One work dir per video (see tools/workdir.mjs) — `lines.json` has the same
|
|
85
|
+
fixed name for every video, so a shared dir would mix two videos' alignments. */
|
|
86
|
+
claimWorkDir(WORK, script, 'align');
|
|
87
|
+
|
|
88
|
+
const timelinePath = join(workDir, 'timeline.json');
|
|
89
|
+
const tl = existsSync(timelinePath) ? JSON.parse(readFileSync(timelinePath, 'utf8')) : null;
|
|
90
|
+
const windows = (tl && tl.windows) || {};
|
|
91
|
+
const segInfo = Object.fromEntries(((tl && tl.segments) || []).map(s => [s.key, s]));
|
|
92
|
+
|
|
93
|
+
const loaded = loadKey();
|
|
94
|
+
const apiKey = loaded.key;
|
|
95
|
+
if (!apiKey) {
|
|
96
|
+
console.error('No API key found (' + describe(loaded.source, apiKey) + ').\n ' + storeHint(loaded.source));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ---------------- text handling ---------------- */
|
|
101
|
+
const NUM = {
|
|
102
|
+
zero: '0', one: '1', two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7',
|
|
103
|
+
eight: '8', nine: '9', ten: '10', eleven: '11', twelve: '12', thirteen: '13',
|
|
104
|
+
fourteen: '14', fifteen: '15', sixteen: '16', seventeen: '17', eighteen: '18',
|
|
105
|
+
nineteen: '19', twenty: '20', thirty: '30', forty: '40', fifty: '50', sixty: '60',
|
|
106
|
+
seventy: '70', eighty: '80', ninety: '90',
|
|
107
|
+
};
|
|
108
|
+
/* Compare on meaning, not spelling: the transcriber writes "40" where we wrote
|
|
109
|
+
"forty", and attaches punctuation to tokens. */
|
|
110
|
+
const normWord = w => {
|
|
111
|
+
const t = String(w).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
112
|
+
return NUM[t] || t;
|
|
113
|
+
};
|
|
114
|
+
/* Our punctuation decides sentence boundaries — never the transcript's. */
|
|
115
|
+
function splitLines(text) {
|
|
116
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
117
|
+
const lines = [];
|
|
118
|
+
let from = 0;
|
|
119
|
+
for (let i = 0; i < words.length; i++) {
|
|
120
|
+
if (/[.!?]["')\]]?$/.test(words[i])) { lines.push({ from, to: i }); from = i + 1; }
|
|
121
|
+
}
|
|
122
|
+
if (from < words.length) lines.push({ from, to: words.length - 1 });
|
|
123
|
+
return { words, lines };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* align script tokens -> transcript indices (longest common subsequence) */
|
|
127
|
+
function alignLCS(a, b) {
|
|
128
|
+
const n = a.length, m = b.length;
|
|
129
|
+
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
130
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
131
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
132
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const map = new Int32Array(n).fill(-1);
|
|
136
|
+
let i = 0, j = 0;
|
|
137
|
+
while (i < n && j < m) {
|
|
138
|
+
if (a[i] === b[j]) { map[i] = j; i++; j++; }
|
|
139
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) i++;
|
|
140
|
+
else j++;
|
|
141
|
+
}
|
|
142
|
+
return map;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* The transcriber occasionally emits an outlier offset: on the module-07 plan
|
|
146
|
+
"A delta" came back at 3.7s in a sentence whose neighbours were at 12.4s and
|
|
147
|
+
14.2s. Offsets are therefore forced monotonic and non-overlapping — a clamp, not
|
|
148
|
+
a repair: an early outlier is pushed to the end of the word before it. */
|
|
149
|
+
function clampMonotonic(words) {
|
|
150
|
+
let fixed = 0;
|
|
151
|
+
let prevEnd = -Infinity;
|
|
152
|
+
for (const w of words) {
|
|
153
|
+
if (w.s < prevEnd) { w.s = prevEnd; fixed++; }
|
|
154
|
+
if (w.e <= w.s) { w.e = +(w.s + 0.05).toFixed(3); fixed++; }
|
|
155
|
+
prevEnd = w.e;
|
|
156
|
+
}
|
|
157
|
+
return fixed;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sha1 = s => createHash('sha1').update(s).digest('hex');
|
|
161
|
+
const durOf = f => parseFloat(execFileSync('ffprobe',
|
|
162
|
+
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f]).toString().trim());
|
|
163
|
+
|
|
164
|
+
/* ---------------- which segments need measuring ---------------- */
|
|
165
|
+
const want = ONLY ? script.segments.filter(s => s.key === ONLY) : script.segments;
|
|
166
|
+
if (!want.length) { console.error('no segments matched' + (ONLY ? ' --only ' + ONLY : '')); process.exit(1); }
|
|
167
|
+
|
|
168
|
+
const errors = [];
|
|
169
|
+
const warnings = [];
|
|
170
|
+
const pending = [];
|
|
171
|
+
const cacheOf = new Map();
|
|
172
|
+
|
|
173
|
+
for (const seg of want) {
|
|
174
|
+
const wavPath = join(workDir, 'narration', seg.key + '.wav');
|
|
175
|
+
const cachePath = join(workDir, 'narration', seg.key + '.words.json');
|
|
176
|
+
if (!existsSync(wavPath)) { errors.push(seg.key + ': no narration/' + seg.key + '.wav — run build-audio first'); continue; }
|
|
177
|
+
const st = statSync(wavPath);
|
|
178
|
+
const textHash = sha1(seg.text);
|
|
179
|
+
if (!FORCE && existsSync(cachePath)) {
|
|
180
|
+
try {
|
|
181
|
+
const c = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
182
|
+
if (c.textHash === textHash && c.wav && c.wav.bytes === st.size && c.wav.mtimeMs === Math.round(st.mtimeMs)) {
|
|
183
|
+
cacheOf.set(seg.key, c);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
} catch (e) { /* corrupt cache: re-transcribe */ }
|
|
187
|
+
}
|
|
188
|
+
pending.push({ seg: seg, wavPath: wavPath, cachePath: cachePath, bytes: st.size, mtimeMs: Math.round(st.mtimeMs), textHash: textHash });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log('\n[align] ' + want.length + ' segment(s) · model ' + MODEL + ' · ' + cacheOf.size + ' cached · ' + pending.length + ' to measure');
|
|
192
|
+
|
|
193
|
+
/* ---------------- choose the request shape ---------------- */
|
|
194
|
+
const trackPath = join(workDir, 'narration.wav');
|
|
195
|
+
const canWholeTrack = existsSync(trackPath) && pending.every(p => segInfo[p.seg.key]);
|
|
196
|
+
let mode = FORCE_PER_SEG ? 'per-segment'
|
|
197
|
+
: FORCE_WHOLE ? 'whole-track'
|
|
198
|
+
: (!pending.length) ? 'none'
|
|
199
|
+
: (pending.length > AUTO_WHOLE_TRACK_ABOVE && canWholeTrack) ? 'whole-track'
|
|
200
|
+
: 'per-segment';
|
|
201
|
+
|
|
202
|
+
if (mode === 'whole-track' && !canWholeTrack) {
|
|
203
|
+
warnings.push('narration.wav or timeline segment offsets are missing — falling back to per-segment');
|
|
204
|
+
mode = 'per-segment';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function wordsFromFile(path) {
|
|
208
|
+
const res = await transcribeWords({ wavPath: path, key: apiKey });
|
|
209
|
+
return res.words;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Absolute-offset words across the whole narration track, chunked if needed. */
|
|
213
|
+
async function wholeTrackWords() {
|
|
214
|
+
const total = durOf(trackPath);
|
|
215
|
+
const chunks = [];
|
|
216
|
+
if (total <= MAX_PASS) {
|
|
217
|
+
chunks.push({ start: 0, len: total });
|
|
218
|
+
} else {
|
|
219
|
+
/* cut at SEGMENT boundaries so no sentence is split across two passes */
|
|
220
|
+
const starts = Array.from(new Set(pending.map(p => segInfo[p.seg.key].start))).sort((a, b) => a - b);
|
|
221
|
+
let from = 0;
|
|
222
|
+
const marks = starts.concat([total]);
|
|
223
|
+
for (const s of marks) {
|
|
224
|
+
while (s - from > MAX_PASS) {
|
|
225
|
+
chunks.push({ start: from, len: MAX_PASS });
|
|
226
|
+
from += MAX_PASS;
|
|
227
|
+
}
|
|
228
|
+
if (s === total) {
|
|
229
|
+
if (total - from > 0.5) chunks.push({ start: from, len: total - from });
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!chunks.length) chunks.push({ start: 0, len: total });
|
|
234
|
+
warnings.push('track is ' + (total / 60).toFixed(1) + ' min — split into ' + chunks.length +
|
|
235
|
+
' pass(es) at segment boundaries (model cap is 30 min with word timestamps)');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const out = [];
|
|
239
|
+
const tmp = chunks.length > 1 ? mkdtempSync(join(tmpdir(), 'align-')) : null;
|
|
240
|
+
try {
|
|
241
|
+
for (let ci = 0; ci < chunks.length; ci++) {
|
|
242
|
+
const c = chunks[ci];
|
|
243
|
+
let file = trackPath;
|
|
244
|
+
if (tmp) {
|
|
245
|
+
file = join(tmp, 'chunk-' + ci + '.wav');
|
|
246
|
+
execFileSync('ffmpeg', ['-y', '-v', 'error', '-ss', String(c.start), '-t', String(c.len), '-i', trackPath, file]);
|
|
247
|
+
}
|
|
248
|
+
const ws = await wordsFromFile(file);
|
|
249
|
+
for (const w of ws) out.push({ w: w.w, s: +(c.start + w.s).toFixed(3), e: +(c.start + w.e).toFixed(3) });
|
|
250
|
+
if (SLEEP) await new Promise(r => setTimeout(r, SLEEP));
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ---------------- obtain words for the pending segments ---------------- */
|
|
259
|
+
const wordsFor = new Map();
|
|
260
|
+
if (mode === 'whole-track' && pending.length) {
|
|
261
|
+
let trackWords = null;
|
|
262
|
+
try {
|
|
263
|
+
process.stdout.write(' whole track… ');
|
|
264
|
+
trackWords = await wholeTrackWords();
|
|
265
|
+
console.log(trackWords.length + ' words');
|
|
266
|
+
} catch (e) {
|
|
267
|
+
const msg = e.message.split('\n')[0];
|
|
268
|
+
/* A daily-cap failure (429) will fail identically for every segment, so falling
|
|
269
|
+
back would burn the whole run on doomed requests. Stop and report instead. */
|
|
270
|
+
if (/429|quota/i.test(msg)) {
|
|
271
|
+
console.error('\n ✗ transcribe quota exhausted — ' + msg);
|
|
272
|
+
console.error(' Whole-track mode needs ONE request; per-segment fallback would need ' +
|
|
273
|
+
pending.length + '. Re-run after the daily reset.');
|
|
274
|
+
process.exit(3);
|
|
275
|
+
}
|
|
276
|
+
warnings.push('whole-track pass failed (' + msg + ') — falling back to per-segment');
|
|
277
|
+
mode = 'per-segment';
|
|
278
|
+
}
|
|
279
|
+
if (trackWords) {
|
|
280
|
+
for (const p of pending) {
|
|
281
|
+
const info = segInfo[p.seg.key];
|
|
282
|
+
/* the track holds `start` seconds of lead-in before this segment's audio */
|
|
283
|
+
const rel = trackWords
|
|
284
|
+
.filter(w => w.s >= info.start - 0.05 && w.s < info.end + 0.05)
|
|
285
|
+
.map(w => ({ w: w.w, s: +(w.s - info.start).toFixed(3), e: +(w.e - info.start).toFixed(3) }));
|
|
286
|
+
if (!rel.length) errors.push(p.seg.key + ': whole-track pass produced no words inside ' + info.start + '–' + info.end + 's');
|
|
287
|
+
else wordsFor.set(p.seg.key, rel);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (mode === 'per-segment') {
|
|
293
|
+
for (const p of pending) {
|
|
294
|
+
process.stdout.write(' ' + p.seg.key.padEnd(24) + ' transcribing… ');
|
|
295
|
+
try {
|
|
296
|
+
wordsFor.set(p.seg.key, await wordsFromFile(p.wavPath));
|
|
297
|
+
console.log('ok');
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.log('FAILED');
|
|
300
|
+
errors.push(p.seg.key + ': ' + e.message.split('\n')[0]);
|
|
301
|
+
}
|
|
302
|
+
if (SLEEP) await new Promise(r => setTimeout(r, SLEEP));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ---------------- align each segment, emit caches + line spans ---------------- */
|
|
307
|
+
const out = {};
|
|
308
|
+
for (const seg of want) {
|
|
309
|
+
const wavPath = join(workDir, 'narration', seg.key + '.wav');
|
|
310
|
+
if (!existsSync(wavPath)) continue;
|
|
311
|
+
|
|
312
|
+
let words, matched, total;
|
|
313
|
+
const cached = cacheOf.get(seg.key);
|
|
314
|
+
const fresh = wordsFor.get(seg.key);
|
|
315
|
+
|
|
316
|
+
if (cached && !fresh) {
|
|
317
|
+
words = cached.words; matched = cached.matched; total = cached.total;
|
|
318
|
+
const fixed = clampMonotonic(words);
|
|
319
|
+
if (fixed) {
|
|
320
|
+
warnings.push(seg.key + ': ' + fixed + ' word offset(s) in the cached transcript were out of order — clamped and re-saved');
|
|
321
|
+
/* write the repair back, so the warning appears once rather than every run */
|
|
322
|
+
try {
|
|
323
|
+
const cp = join(workDir, 'narration', seg.key + '.words.json');
|
|
324
|
+
writeFileSync(cp, JSON.stringify({ ...cached, words: words }, null, 1) + '\n');
|
|
325
|
+
} catch (e) { /* cache is advisory; a failed rewrite is not fatal */ }
|
|
326
|
+
}
|
|
327
|
+
} else if (fresh) {
|
|
328
|
+
const fixed = clampMonotonic(fresh);
|
|
329
|
+
if (fixed) warnings.push(seg.key + ': ' + fixed + ' word offset(s) came back out of order and were clamped');
|
|
330
|
+
const parts = splitLines(seg.text);
|
|
331
|
+
const map = alignLCS(parts.words.map(normWord), fresh.map(w => normWord(w.w)));
|
|
332
|
+
matched = Array.from(map).filter(x => x >= 0).length;
|
|
333
|
+
total = parts.words.length;
|
|
334
|
+
const inv = new Int32Array(fresh.length).fill(-1);
|
|
335
|
+
for (let k = 0; k < map.length; k++) if (map[k] >= 0) inv[map[k]] = k;
|
|
336
|
+
words = fresh.map((w, j) => ({ w: w.w, s: w.s, e: w.e, si: inv[j] }));
|
|
337
|
+
const p = pending.find(x => x.seg.key === seg.key);
|
|
338
|
+
writeFileSync(p.cachePath, JSON.stringify({
|
|
339
|
+
key: seg.key, model: MODEL, text: null, textHash: p.textHash,
|
|
340
|
+
wav: { bytes: p.bytes, mtimeMs: p.mtimeMs }, matched: matched, total: total, words: words,
|
|
341
|
+
}, null, 1) + '\n');
|
|
342
|
+
} else {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* forward map script-word -> transcript-word, from the stored `si` */
|
|
347
|
+
const parts2 = splitLines(seg.text);
|
|
348
|
+
const sw = parts2.words, sent = parts2.lines;
|
|
349
|
+
const fwd = new Int32Array(sw.length).fill(-1);
|
|
350
|
+
words.forEach((w, j) => { if (w.si >= 0 && w.si < sw.length) fwd[w.si] = j; });
|
|
351
|
+
|
|
352
|
+
const ratio = matched / Math.max(1, total);
|
|
353
|
+
if (ratio < MIN_MATCH) warnings.push(seg.key + ': only ' + (ratio * 100).toFixed(0) + '% of script words aligned (min ' + (MIN_MATCH * 100) + '%)');
|
|
354
|
+
|
|
355
|
+
const lines = [];
|
|
356
|
+
for (let li = 0; li < sent.length; li++) {
|
|
357
|
+
const s = sent[li];
|
|
358
|
+
let first = -1, last = -1;
|
|
359
|
+
for (let k = s.from; k <= s.to; k++) if (fwd[k] >= 0) { first = fwd[k]; break; }
|
|
360
|
+
for (let k = s.to; k >= s.from; k--) if (fwd[k] >= 0) { last = fwd[k]; break; }
|
|
361
|
+
if (first < 0 || last < 0) {
|
|
362
|
+
errors.push(seg.key + ': line ' + li + ' ("' + sw.slice(s.from, s.to + 1).join(' ').slice(0, 40) + '…") aligned to no words');
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
lines.push({
|
|
366
|
+
line: li,
|
|
367
|
+
text: sw.slice(s.from, s.to + 1).join(' '),
|
|
368
|
+
start: +words[first].s.toFixed(3),
|
|
369
|
+
end: +words[last].e.toFixed(3),
|
|
370
|
+
wordFrom: s.from, wordTo: s.to,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const wavDur = durOf(wavPath);
|
|
375
|
+
for (let i = 1; i < lines.length; i++) {
|
|
376
|
+
if (lines[i].start < lines[i - 1].start) errors.push(seg.key + ': line ' + i + ' starts before line ' + (i - 1));
|
|
377
|
+
}
|
|
378
|
+
for (const L of lines) {
|
|
379
|
+
if (L.start < -0.05 || L.end > wavDur + 0.5) {
|
|
380
|
+
errors.push(seg.key + ': line ' + L.line + ' spans ' + L.start + '–' + L.end + 's, outside the ' + wavDur.toFixed(2) + 's audio');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const win = windows[seg.key];
|
|
385
|
+
const info = segInfo[seg.key];
|
|
386
|
+
const lead = (win && info) ? +(info.start - win.start).toFixed(3) : null;
|
|
387
|
+
out[seg.key] = {
|
|
388
|
+
total: +wavDur.toFixed(3), lead: lead, textHash: sha1(seg.text),
|
|
389
|
+
window: win ? { start: win.start, end: win.end } : null,
|
|
390
|
+
words: total, matched: matched, lines: lines,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* ---------------- emit ---------------- */
|
|
395
|
+
const linesPath = join(workDir, 'lines.json');
|
|
396
|
+
writeFileSync(linesPath, JSON.stringify({
|
|
397
|
+
generatedFrom: {
|
|
398
|
+
script: scriptPath.replace(process.cwd() + '/', ''),
|
|
399
|
+
aligner: 'tools/align.mjs',
|
|
400
|
+
model: MODEL,
|
|
401
|
+
mode: mode,
|
|
402
|
+
at: new Date().toISOString(),
|
|
403
|
+
},
|
|
404
|
+
note: 'Line times are relative to the narration WAV start. Scene time = line.start + lead.',
|
|
405
|
+
segments: out,
|
|
406
|
+
}, null, 1) + '\n');
|
|
407
|
+
|
|
408
|
+
console.log(' mode: ' + mode);
|
|
409
|
+
if (warnings.length) { console.log('\n ⚠ ' + warnings.length + ' warning(s):'); warnings.forEach(w => console.log(' - ' + w)); }
|
|
410
|
+
if (errors.length) {
|
|
411
|
+
console.log('\n ✗ ' + errors.length + ' error(s):');
|
|
412
|
+
errors.forEach(e => console.log(' - ' + e));
|
|
413
|
+
process.exitCode = 2;
|
|
414
|
+
} else {
|
|
415
|
+
const tot = Object.values(out).reduce((a, s) => a + s.lines.length, 0);
|
|
416
|
+
console.log('\n ✓ ' + Object.keys(out).length + ' segment(s) · ' + tot + ' lines · written:');
|
|
417
|
+
console.log(' ' + linesPath.replace(process.cwd() + '/', ''));
|
|
418
|
+
process.exitCode = 0;
|
|
419
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* =============================================================================
|
|
3
|
+
apply-enrichment.mjs — deterministic merge of agent judgment into a plan.
|
|
4
|
+
|
|
5
|
+
WHY THIS EXISTS, AND WHY IT IS NOT AN API CALL
|
|
6
|
+
----------------------------------------------
|
|
7
|
+
Naming videos, choosing their visual form, and pruning redundant overview
|
|
8
|
+
videos is JUDGMENT. The host agent is already an LLM sitting in the loop with
|
|
9
|
+
the plan, the sources and the user's stated preferences — so it does the
|
|
10
|
+
judging and writes `enrichment.json`.
|
|
11
|
+
|
|
12
|
+
This script does what code should: merge, validate, and report. It never
|
|
13
|
+
guesses. If the agent is unavailable, nothing happens and the deterministic
|
|
14
|
+
plan still stands.
|
|
15
|
+
|
|
16
|
+
THE TITLE CONTRACT (what the agent must produce)
|
|
17
|
+
------------------------------------------------
|
|
18
|
+
One entry per video, each a real title — descriptive, unique, and written
|
|
19
|
+
for a human scrolling YouTube, not a directory name. `apply-enrichment`
|
|
20
|
+
enforces the floor: it rejects empty titles, placeholders ("Part N"),
|
|
21
|
+
ellipses, directory-slug titles ("load-balancing"), and duplicates.
|
|
22
|
+
|
|
23
|
+
CURATION (skip / merge)
|
|
24
|
+
------------------------
|
|
25
|
+
A deterministic planner cannot tell an overview page from real content, so it
|
|
26
|
+
plans both. The agent decides, and records the decision instead of letting it
|
|
27
|
+
be implicit:
|
|
28
|
+
{ "index": 33, "skip": true, "why": "phase index — content lives in #34/#35" }
|
|
29
|
+
{ "index": 33, "mergeInto": 35, "why": "fold the 'why this phase' prose" }
|
|
30
|
+
Both require a stated reason. Skip drops the video and records its sections;
|
|
31
|
+
mergeInto folds the video's sections and words into the target. Neither is
|
|
32
|
+
silent — both land in `plan.removed` with the reason.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
node tools/apply-enrichment.mjs --plan out/<name>/plan.json \
|
|
36
|
+
--enrichment out/<name>/enrichment.json
|
|
37
|
+
========================================================================== */
|
|
38
|
+
|
|
39
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
40
|
+
import { join, dirname } from 'node:path';
|
|
41
|
+
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const flag = (n, d) => {
|
|
44
|
+
const i = argv.indexOf('--' + n);
|
|
45
|
+
return i === -1 ? d : argv[i + 1];
|
|
46
|
+
};
|
|
47
|
+
const PLAN = flag('plan');
|
|
48
|
+
const ENR = flag('enrichment');
|
|
49
|
+
if (!PLAN || !ENR) {
|
|
50
|
+
console.error('usage: node tools/apply-enrichment.mjs --plan <plan.json> --enrichment <enrichment.json>');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
for (const p of [PLAN, ENR]) if (!existsSync(p)) { console.error('not found: ' + p); process.exit(1); }
|
|
54
|
+
|
|
55
|
+
const plan = JSON.parse(readFileSync(PLAN, 'utf8'));
|
|
56
|
+
const enrichment = JSON.parse(readFileSync(ENR, 'utf8'));
|
|
57
|
+
const OUT_DIR = dirname(PLAN);
|
|
58
|
+
|
|
59
|
+
const FORMS = plan.forms || ['concept', 'diagram', 'code', 'refactor', 'catalogue']; // a code-flow plan declares its own
|
|
60
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)+$/; // "load-balancing" — a dir slug, not a title
|
|
61
|
+
const problems = [];
|
|
62
|
+
const changes = [];
|
|
63
|
+
const byIndex = new Map((enrichment.videos || []).map(v => [Number(v.index), v]));
|
|
64
|
+
const ids = new Set(plan.videos.map(v => v.index));
|
|
65
|
+
|
|
66
|
+
// ---------- pass 1: read curation intent (skip / mergeInto), validate it ----
|
|
67
|
+
const skipSet = new Set();
|
|
68
|
+
const mergeMap = new Map(); // srcIndex -> targetIndex
|
|
69
|
+
for (const v of plan.videos) {
|
|
70
|
+
const e = byIndex.get(v.index);
|
|
71
|
+
if (!e) continue;
|
|
72
|
+
const skip = !!e.skip;
|
|
73
|
+
const merge = e.mergeInto != null ? Number(e.mergeInto) : null;
|
|
74
|
+
if (skip && merge != null) problems.push(`video ${v.index}: both skip and mergeInto set — choose one`);
|
|
75
|
+
if (skip) {
|
|
76
|
+
if (!String(e.why || '').trim()) problems.push(`video ${v.index}: skip requires a stated reason ("why")`);
|
|
77
|
+
skipSet.add(v.index);
|
|
78
|
+
}
|
|
79
|
+
if (merge != null) {
|
|
80
|
+
if (!String(e.why || '').trim()) problems.push(`video ${v.index}: mergeInto requires a stated reason ("why")`);
|
|
81
|
+
mergeMap.set(v.index, merge);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [src, tgt] of mergeMap) {
|
|
85
|
+
if (src === tgt) problems.push(`video ${src}: mergeInto must target a different video`);
|
|
86
|
+
else if (!ids.has(tgt)) problems.push(`video ${src}: mergeInto references ${tgt}, which is not in the plan`);
|
|
87
|
+
else if (skipSet.has(tgt)) problems.push(`video ${src}: mergeInto target ${tgt} is itself skipped`);
|
|
88
|
+
else if (mergeMap.has(tgt)) problems.push(`video ${src}: mergeInto target ${tgt} is itself merged — chains unsupported`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------- pass 2: title + form, for the videos that survive --------------
|
|
92
|
+
const seenTitles = new Map(); // title -> index (uniqueness among survivors)
|
|
93
|
+
plan.videos.forEach(v => {
|
|
94
|
+
const e = byIndex.get(v.index);
|
|
95
|
+
v.before = { title: v.title, form: v.form };
|
|
96
|
+
if (!e) { problems.push(`video ${v.index}: no enrichment entry`); return; }
|
|
97
|
+
const removed = skipSet.has(v.index) || mergeMap.has(v.index);
|
|
98
|
+
|
|
99
|
+
if (!removed) {
|
|
100
|
+
// --- title ---
|
|
101
|
+
const t = String(e.title || '').trim();
|
|
102
|
+
if (!t) problems.push(`video ${v.index}: empty title`);
|
|
103
|
+
else if (t.length > 70) problems.push(`video ${v.index}: title too long (${t.length}): "${t}"`);
|
|
104
|
+
else if (/\.\.\.|…/.test(t)) problems.push(`video ${v.index}: title contains an ellipsis: "${t}"`);
|
|
105
|
+
else if (/^part\s+\d+$/i.test(t)) problems.push(`video ${v.index}: title is a placeholder: "${t}"`);
|
|
106
|
+
else if (SLUG_RE.test(t)) problems.push(`video ${v.index}: title "${t}" is a directory slug — write a real title`);
|
|
107
|
+
else if (seenTitles.has(t)) problems.push(`video ${v.index}: duplicate title "${t}" (already used by video ${seenTitles.get(t)})`);
|
|
108
|
+
else { v.title = t; seenTitles.set(t, v.index); }
|
|
109
|
+
|
|
110
|
+
// --- form ---
|
|
111
|
+
const f = String(e.form || '').trim();
|
|
112
|
+
if (!FORMS.includes(f)) problems.push(`video ${v.index}: form "${f}" not in [${FORMS.join(', ')}]`);
|
|
113
|
+
else v.form = f;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
v.formWhy = e.why ? String(e.why) : v.formWhy;
|
|
117
|
+
|
|
118
|
+
if (v.before.title !== v.title || v.before.form !== v.form) {
|
|
119
|
+
changes.push({ index: v.index, from: v.before, to: { title: v.title, form: v.form } });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// a video must not silently vanish from the enrichment
|
|
124
|
+
for (const i of byIndex.keys()) {
|
|
125
|
+
if (!ids.has(i)) problems.push(`enrichment references video ${i}, which is not in the plan`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------- apply merges: fold sections + words into the target -------------
|
|
129
|
+
const byId = new Map(plan.videos.map(v => [v.index, v]));
|
|
130
|
+
const model = plan.model || { wpm: 153, expansion: 1.1 };
|
|
131
|
+
for (const [src, tgt] of mergeMap) {
|
|
132
|
+
const s = byId.get(src), t = byId.get(tgt);
|
|
133
|
+
if (!s || !t) continue;
|
|
134
|
+
t.sections = (t.sections || []).concat(s.sections || []);
|
|
135
|
+
t.sectionSpans = (t.sectionSpans || []).concat(s.sectionSpans || []);
|
|
136
|
+
t.words = (t.words || 0) + (s.words || 0);
|
|
137
|
+
t.seconds = Math.round((t.words / model.wpm) * 60 * model.expansion);
|
|
138
|
+
t.minutes = +(t.seconds / 60).toFixed(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------- remove skipped / merged videos, recording what happened ---------
|
|
142
|
+
const removed = [];
|
|
143
|
+
plan.videos = plan.videos.filter(v => {
|
|
144
|
+
const e = byIndex.get(v.index);
|
|
145
|
+
const skip = skipSet.has(v.index);
|
|
146
|
+
const merge = mergeMap.has(v.index);
|
|
147
|
+
if (skip || merge) {
|
|
148
|
+
removed.push({
|
|
149
|
+
index: v.index,
|
|
150
|
+
title: v.before.title,
|
|
151
|
+
action: skip ? 'skip' : `mergeInto ${mergeMap.get(v.index)}`,
|
|
152
|
+
reason: e && e.why ? String(e.why) : null,
|
|
153
|
+
sections: v.sections || [],
|
|
154
|
+
});
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------- recompute totals that curation changed --------------------------
|
|
161
|
+
plan.totals.videos = plan.videos.length;
|
|
162
|
+
plan.totals.seriesMinutes = +plan.videos.reduce((a, v) => a + v.minutes, 0).toFixed(1);
|
|
163
|
+
plan.totals.longestVideoMin = Math.max(...plan.videos.map(v => v.minutes));
|
|
164
|
+
|
|
165
|
+
plan.enrichment = {
|
|
166
|
+
source: 'host-agent judgment (' + ENR + ')',
|
|
167
|
+
seriesNote: enrichment.seriesNote || null,
|
|
168
|
+
revised: changes.length,
|
|
169
|
+
removed,
|
|
170
|
+
validationProblems: problems,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
writeFileSync(join(OUT_DIR, 'plan.enriched.json'), JSON.stringify(plan, null, 2));
|
|
174
|
+
|
|
175
|
+
/* ---------------- report ---------------- */
|
|
176
|
+
const L = [];
|
|
177
|
+
L.push(`# ${plan.generatedFrom} — enriched plan`, '');
|
|
178
|
+
if (plan.enrichment.seriesNote) L.push(`> ${plan.enrichment.seriesNote}`, '');
|
|
179
|
+
L.push(`${plan.totals.videos} videos · ${plan.totals.seriesMinutes} min · longest ${plan.totals.longestVideoMin} min (cap ${plan.totals.capMinutes}) · **${changes.length} revised**${removed.length ? ` · ${removed.length} removed` : ''}`, '');
|
|
180
|
+
L.push('| # | Title | Form | Min | Previously |');
|
|
181
|
+
L.push('|---|---|---|---|---|');
|
|
182
|
+
plan.videos.forEach(v => {
|
|
183
|
+
const wasT = v.before.title === v.title ? '—' : `"${v.before.title}"`;
|
|
184
|
+
const wasF = v.before.form === v.form ? '' : ` _(form: ${v.before.form})_`;
|
|
185
|
+
L.push(`| ${v.index} | **${v.title}** | \`${v.form}\` | ${v.minutes} | ${wasT}${wasF} |`);
|
|
186
|
+
});
|
|
187
|
+
L.push('', '### Why each form', '');
|
|
188
|
+
plan.videos.forEach(v => L.push(`- **${v.index}. ${v.title}** → \`${v.form}\` — ${v.formWhy}`));
|
|
189
|
+
if (removed.length) {
|
|
190
|
+
L.push('', '### Removed by curation', '');
|
|
191
|
+
removed.forEach(r => L.push(`- **${r.index}. ${r.before || r.title}** → ${r.action} — ${r.reason || '(no reason)'}`));
|
|
192
|
+
}
|
|
193
|
+
L.push('', '### Coverage (carried from the deterministic planner)', '');
|
|
194
|
+
L.push(`${plan.coverage.length} headings · ${plan.orphans.length} orphaned`);
|
|
195
|
+
L.push(plan.orphans.length ? '⚠️ orphans present' : '✅ No orphans — every content-bearing heading is assigned to a video or is a navigation heading.');
|
|
196
|
+
if (problems.length) {
|
|
197
|
+
L.push('', '### ⚠️ Validation problems', '');
|
|
198
|
+
problems.forEach(p => L.push(`- ${p}`));
|
|
199
|
+
}
|
|
200
|
+
writeFileSync(join(OUT_DIR, 'plan.enriched.md'), L.join('\n') + '\n');
|
|
201
|
+
|
|
202
|
+
/* print the whole final list — every video, not the first handful */
|
|
203
|
+
console.log(L.join('\n'));
|
|
204
|
+
console.log(`\n[out] ${join(OUT_DIR, 'plan.enriched.json')}`);
|
|
205
|
+
console.log(`[out] ${join(OUT_DIR, 'plan.enriched.md')}`);
|
|
206
|
+
if (problems.length) { console.log(`\n⚠️ ${problems.length} validation problem(s)`); process.exitCode = 2; }
|