explainer-video 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +69 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  24. package/skills/explainer-video-from-codebase/SKILL.md +659 -0
  25. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  26. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  27. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  28. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  29. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  30. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  31. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  32. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  33. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  35. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  37. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  38. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  45. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  59. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  60. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  61. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  62. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  63. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  81. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  88. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  89. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  106. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
  107. package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
  108. package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
  109. package/skills/explainer-video-from-coursework/SERIES.md +232 -0
  110. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  111. package/skills/explainer-video-from-coursework/SKILL.md +858 -0
  112. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  118. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  120. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  121. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  122. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  124. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  125. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  126. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  127. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  128. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  129. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  130. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  131. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  132. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  133. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  134. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  135. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  136. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  137. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  138. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  139. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  140. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  141. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  142. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  143. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  144. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  145. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  146. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  147. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  148. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  149. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  150. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ script-check.mjs — validate a narration script before it costs anything.
4
+
5
+ The script writer is the AGENT (judgment). This tool is CODE (exactness), and
6
+ it enforces the same discipline the planner does for coverage:
7
+
8
+ 1. STRUCTURE unique scene keys, non-empty narration, a style per segment
9
+ 2. SPEAKABILITY no markdown, no URLs, no code syntax — this is read aloud
10
+ 3. BUDGET estimated narration + video duration vs the plan's target
11
+ 4. COVERAGE every source section the plan assigned must be narrated
12
+ 5. PAUSES code videos only, inert unless a segment carries `pause`:
13
+ a pause prompt is spoken ("pause" in its text), dwells
14
+ 0 < dwell <= 2 s realised as an equal `pad`, and a video has
15
+ at most 3; a fourth is an error that forces a split
16
+
17
+ Duration is ESTIMATED here, never trusted: the real number comes from measuring
18
+ the generated audio downstream.
19
+
20
+ Usage:
21
+ node tools/script-check.mjs --script out/<name>/scripts/NN-<slug>.json \
22
+ [--plan out/<name>/plan.enriched.json] [--max-min 25]
23
+ ========================================================================== */
24
+
25
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
26
+ import { join, basename } from 'node:path';
27
+ import { WPM as DEFAULT_WPM, EXPANSION as DEFAULT_EXPANSION, MAX_PAUSES } from './model.mjs';
28
+
29
+ const argv = process.argv.slice(2);
30
+ const flag = (n, d) => {
31
+ const i = argv.indexOf('--' + n);
32
+ return i === -1 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
33
+ };
34
+
35
+ /* same cost model as the planner, so the two are comparable */
36
+ const WPM = parseFloat(flag('wpm', DEFAULT_WPM));
37
+ const EXPANSION = parseFloat(flag('expansion', DEFAULT_EXPANSION));
38
+ const MAX_MIN = parseFloat(flag('max-min', '25'));
39
+ const MIN_FILL = parseFloat(flag('min-fill', '0.55')); // script must reach 55% of target
40
+
41
+ const scriptPath = flag('script');
42
+ const planPath = flag('plan');
43
+ if (!scriptPath) {
44
+ console.error('usage: node tools/script-check.mjs --script <script.json> [--plan plan.enriched.json]');
45
+ process.exit(1);
46
+ }
47
+ if (!existsSync(scriptPath)) { console.error('not found: ' + scriptPath); process.exit(1); }
48
+
49
+ const script = JSON.parse(readFileSync(scriptPath, 'utf8'));
50
+ const errors = [];
51
+ const warnings = [];
52
+
53
+ /* ---------------- helpers ---------------- */
54
+ const words = s => String(s || '').split(/\s+/).filter(Boolean).length;
55
+ const narrationMin = w => (w / WPM);
56
+ const videoMin = w => (w / WPM) * EXPANSION;
57
+
58
+ /* ---------------- 1. structure ---------------- */
59
+ if (!script.videoIndex && script.videoIndex !== 0) errors.push('missing videoIndex');
60
+ if (!script.title) errors.push('missing title');
61
+ if (!Array.isArray(script.segments) || !script.segments.length) errors.push('no segments');
62
+
63
+ const seen = new Set();
64
+ (script.segments || []).forEach((s, i) => {
65
+ const at = `segment ${i + 1}${s.key ? ` (${s.key})` : ''}`;
66
+ if (!s.key) errors.push(`${at}: missing key`);
67
+ else if (seen.has(s.key)) errors.push(`${at}: duplicate key "${s.key}"`);
68
+ else seen.add(s.key);
69
+
70
+ if (!s.text || !String(s.text).trim()) errors.push(`${at}: empty narration`);
71
+ if (!s.style) warnings.push(`${at}: no style — TTS will fall back to a generic delivery`);
72
+ if (!s.visual) warnings.push(`${at}: no visual hint — the scene stage will have to guess`);
73
+ if (!Array.isArray(s.covers) || !s.covers.length) warnings.push(`${at}: does not declare what it covers`);
74
+ });
75
+
76
+ /* ---------------- 2. speakability ---------------- */
77
+ (script.segments || []).forEach((s, i) => {
78
+ const t = String(s.text || '');
79
+ const at = `segment ${i + 1} (${s.key})`;
80
+ if (/https?:\/\//.test(t)) errors.push(`${at}: contains a URL — not speakable`);
81
+ if (/[*_`#>]|\|\s*-{2,}/.test(t)) errors.push(`${at}: contains markdown syntax`);
82
+ if (/[{}();=]|=>|\bdef \b|\bclass \w+\(/.test(t)) warnings.push(`${at}: looks like code in the narration`);
83
+ if (/\b[A-Z]{3,}\b/.test(t)) {
84
+ const acronyms = t.match(/\b[A-Z]{3,}\b/g);
85
+ if (acronyms) warnings.push(`${at}: acronym(s) ${[...new Set(acronyms)].join(', ')} — TTS may spell them out`);
86
+ }
87
+ if (words(t) > 130) warnings.push(`${at}: ${words(t)} words in one breath — consider splitting`);
88
+ });
89
+
90
+ /* ---------------- 5. pause prompts (active only when a segment carries `pause`) ----------------
91
+ Narration is the reading time: a code panel shows only the lines being narrated. A
92
+ pause prompt is the rare exception, for a block that must be read whole: the
93
+ narration says so out loud, a badge shows it, and the segment dwells at most 2 s
94
+ longer (realised as its `pad`). More than MAX_PAUSES means the video asks the
95
+ viewer to read silently too often: split it. */
96
+ const pauses = (script.segments || []).filter(s => s.pause != null);
97
+ let pauseDwell = 0;
98
+ if (pauses.length) {
99
+ pauses.forEach(s => {
100
+ const at = `segment ${script.segments.indexOf(s) + 1} (${s.key})`;
101
+ const dwell = s.pause && typeof s.pause === 'object' ? s.pause.dwell : undefined;
102
+ if (!/\bpause\b/i.test(String(s.text || ''))) errors.push(`${at}: a pause segment's narration must say "pause" — the spoken prompt is what tells the viewer to stop and read`);
103
+ if (typeof dwell !== 'number' || !(dwell > 0 && dwell <= 2)) errors.push(`${at}: pause.dwell must be a number of seconds, 0 < dwell <= 2 (got ${JSON.stringify(dwell)})`);
104
+ else {
105
+ pauseDwell += dwell;
106
+ if (Number(s.pad) !== dwell) errors.push(`${at}: pause.dwell ${dwell}s is realised as the segment's pad, which is ${JSON.stringify(s.pad)} — set "pad": ${dwell}`);
107
+ }
108
+ });
109
+ if (pauses.length > MAX_PAUSES) errors.push(`${pauses.length} pause prompts (max ${MAX_PAUSES}) — a video that asks for that much silent reading must be split`);
110
+ }
111
+
112
+ /* ---------------- 3. budget ---------------- */
113
+ const totalWords = (script.segments || []).reduce((a, s) => a + words(s.text), 0);
114
+ const estNarr = narrationMin(totalWords);
115
+ const estVideo = videoMin(totalWords) + pauseDwell / 60;
116
+ const target = script.planTargetMinutes;
117
+
118
+ if (estVideo > MAX_MIN) errors.push(`estimated ${estVideo.toFixed(1)} min exceeds the ${MAX_MIN} min cap`);
119
+ if (target && estVideo < target * MIN_FILL) {
120
+ errors.push(`estimated ${estVideo.toFixed(1)} min is under ${(MIN_FILL * 100) | 0}% of the ${target} min target — content likely missing`);
121
+ }
122
+ if (target && estVideo > target * 1.25) {
123
+ warnings.push(`estimated ${estVideo.toFixed(1)} min is over 125% of the ${target} min target`);
124
+ }
125
+
126
+ /* ---------------- 4. coverage vs the plan ---------------- */
127
+ let coverageLine = null;
128
+ if (planPath && existsSync(planPath)) {
129
+ const plan = JSON.parse(readFileSync(planPath, 'utf8'));
130
+ /* The plan was split at its own recorded cap. If this run's cap disagrees, the plan
131
+ is from a different model generation — say so rather than silently allowing a
132
+ script far longer than the plan ever expected. */
133
+ const planCap = plan.model && plan.model.maxVideoMin;
134
+ if (planCap != null && Math.abs(planCap - MAX_MIN) > 1e-9) {
135
+ warnings.push(`plan was built with maxVideoMin=${planCap} but this check uses ${MAX_MIN} — the plan predates the current default (re-plan to change the split, which also reshuffles video indices)`);
136
+ }
137
+ const video = plan.videos.find(v => v.index === script.videoIndex);
138
+ if (!video) {
139
+ warnings.push(`video ${script.videoIndex} not found in the plan — skipping coverage check`);
140
+ } else {
141
+ const assigned = new Set(video.sections);
142
+ const covered = new Set((script.segments || []).flatMap(s => s.covers || []));
143
+ const missing = [...assigned].filter(x => !covered.has(x));
144
+ const extra = [...covered].filter(x => !assigned.has(x));
145
+ missing.forEach(m => errors.push(`UNCOVERED source section: ${m}`));
146
+ extra.forEach(x => warnings.push(`covers a section not assigned to this video: ${x}`));
147
+ coverageLine = `${covered.size}/${assigned.size} assigned sections covered`;
148
+ }
149
+ }
150
+
151
+ /* ---------------- report ---------------- */
152
+ const name = basename(scriptPath);
153
+ console.log(`\n${name}`);
154
+ console.log('─'.repeat(Math.min(72, name.length + 20)));
155
+ console.log(` title : ${script.title || '(none)'}`);
156
+ console.log(` form : ${script.form || '(unset)'}`);
157
+ console.log(` segments : ${(script.segments || []).length}`);
158
+ console.log(` words : ${totalWords}`);
159
+ console.log(` narration : ${estNarr.toFixed(1)} min (at ${WPM} wpm)`);
160
+ console.log(` est. video : ${estVideo.toFixed(1)} min (×${EXPANSION} visual overhead)`);
161
+ if (pauses.length) console.log(` pauses : ${pauses.length} prompt(s), ${pauseDwell.toFixed(1)}s of dwell (in est. video)`);
162
+ if (target) console.log(` plan target : ${target} min → ${((estVideo / target) * 100).toFixed(0)}% of target`);
163
+ if (coverageLine) console.log(` coverage : ${coverageLine}`);
164
+ if (warnings.length) {
165
+ console.log(`\n ⚠ ${warnings.length} warning(s):`);
166
+ warnings.forEach(w => console.log(` - ${w}`));
167
+ }
168
+ if (errors.length) {
169
+ console.log(`\n ✗ ${errors.length} error(s):`);
170
+ errors.forEach(e => console.log(` - ${e}`));
171
+ } else {
172
+ console.log('\n ✓ script is valid');
173
+ }
174
+ process.exitCode = errors.length ? 2 : 0;
@@ -0,0 +1,71 @@
1
+ /* =============================================================================
2
+ stamp.mjs — append one per-video stage timing row to out/<name>/timing.jsonl.
3
+
4
+ Usage:
5
+ node tools/stamp.mjs begin --out out/<name> --video <NN> --stage <n> [--note s]
6
+ node tools/stamp.mjs end --out out/<name> --video <NN> --stage <n> [--note s]
7
+
8
+ A stage spans many turns and several processes, so its duration cannot be read
9
+ directly: `begin` writes a throwaway start marker, `end` reads it, subtracts,
10
+ appends one row, and deletes the marker. The marker never enters the ledger.
11
+
12
+ One row per attempt, in long format — the stage dimension lives in the `stage`
13
+ value, never in a column name — so stages can be added, renamed, or merged
14
+ without ever changing the file's shape.
15
+
16
+ { video, stage, attempt, seconds, ended_at, note }
17
+
18
+ `attempt` is 1-based and auto-increments: it counts the rows already recorded for
19
+ this (video, stage) and adds one, so a retry becomes attempt 2 with no bookkeeping.
20
+ ========================================================================== */
21
+
22
+ import { mkdirSync, readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ const argv = process.argv.slice(2);
26
+ const cmd = argv[0];
27
+ const flag = (n, d) => { const i = argv.indexOf('--' + n); return i === -1 ? d : argv[i + 1]; };
28
+
29
+ const STAGES = new Set(['3', '4', '4b', '5', '6', '7', '8']);
30
+ const outDir = flag('out');
31
+ const video = flag('video');
32
+ const stage = flag('stage');
33
+ const note = flag('note', null);
34
+
35
+ const die = (msg) => { console.error('[stamp] ' + msg); process.exit(1); };
36
+
37
+ if (!cmd || !['begin', 'end'].includes(cmd))
38
+ die('usage: stamp.mjs <begin|end> --out <seriesDir> --video <NN> --stage <n> [--note s]');
39
+ if (!outDir) die('missing --out <seriesDir>');
40
+ if (!video) die('missing --video <NN> (the bare plan index)');
41
+ if (!stage || !STAGES.has(stage)) die(`--stage must be one of ${[...STAGES].join(', ')}`);
42
+
43
+ mkdirSync(outDir, { recursive: true });
44
+ const stampsDir = join(outDir, '.stamps');
45
+ const marker = join(stampsDir, `${video}.${stage}.start`);
46
+ const ledger = join(outDir, 'timing.jsonl');
47
+
48
+ if (cmd === 'begin') {
49
+ mkdirSync(stampsDir, { recursive: true });
50
+ if (existsSync(marker))
51
+ console.error(`[stamp] overwriting an existing start marker for video ${video} stage ${stage} — did a previous \`end\` never run?`);
52
+ writeFileSync(marker, String(Date.now()));
53
+ console.log(`[stamp] begin video ${video} stage ${stage}`);
54
+ } else {
55
+ if (!existsSync(marker))
56
+ die(`no start marker for video ${video} stage ${stage} — run \`begin\` first`);
57
+ const startMs = Number(readFileSync(marker, 'utf8').trim());
58
+ const seconds = Math.round((Date.now() - startMs) / 100) / 10; // one decimal
59
+ let attempt = 1;
60
+ if (existsSync(ledger)) {
61
+ const prior = readFileSync(ledger, 'utf8').split('\n').filter(Boolean);
62
+ attempt = prior.reduce((n, line) => {
63
+ try { const r = JSON.parse(line); return (r.video === video && r.stage === stage) ? n + 1 : n; }
64
+ catch { return n; }
65
+ }, 0) + 1;
66
+ }
67
+ const row = { video, stage, attempt, seconds, ended_at: new Date().toISOString(), note };
68
+ appendFileSync(ledger, JSON.stringify(row) + '\n');
69
+ unlinkSync(marker);
70
+ console.log(`[stamp] video ${video} stage ${stage} attempt ${attempt} = ${seconds}s`);
71
+ }
@@ -0,0 +1,299 @@
1
+ /* =============================================================================
2
+ timing-report.mjs — render out/<name>/timing.jsonl into four human-readable views.
3
+
4
+ Usage:
5
+ node tools/timing-report.mjs --out out/<name> [--flat]
6
+
7
+ Reads the append-only ledger (one row per attempt) plus the enriched plan for
8
+ titles, then writes:
9
+
10
+ out/<name>/timing.csv — long format, one row per attempt:
11
+ video_title, stage, attempt, seconds, ended_at, note
12
+ out/<name>/timing.md — one heading per video title, a stage -> seconds table
13
+ (attempts summed) with a total.
14
+ out/<name>/timing.svg — static stacked bars (no-JS fallback; opens anywhere).
15
+ out/<name>/timing.html — interactive Plotly chart, self-contained and offline:
16
+ the vendored bundle and src/timing-chart.js are inlined.
17
+
18
+ Grouping — the plan already groups videos by module (the first path segment of a
19
+ video's first source), so a series of 85 videos is really ~17 modules. When the
20
+ ledger spans more than one module the HTML and SVG show one aggregate bar per
21
+ module and (HTML only) drill into a module's videos on click; with a single module
22
+ grouping would be a pointless extra click, so both render every video instead.
23
+ `--flat` forces the per-video chart regardless.
24
+
25
+ All four outputs are derived and regenerable; the ledger is the only source of
26
+ truth. The stage dimension is a *value*, never a column or a hardcoded colour, so
27
+ adding/removing/merging stages changes the legend — not the drawing code.
28
+ ========================================================================== */
29
+
30
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
31
+ import { join, dirname } from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const argv = process.argv.slice(2);
35
+ const flag = (n, d) => { const i = argv.indexOf('--' + n); return i === -1 ? d : argv[i + 1]; };
36
+ const outDir = flag('out');
37
+ if (!outDir) { console.error('usage: node tools/timing-report.mjs --out <seriesDir> [--flat]'); process.exit(1); }
38
+ const forceFlat = argv.includes('--flat');
39
+
40
+ const ledgerPath = join(outDir, 'timing.jsonl');
41
+ const planPath = join(outDir, 'plan.enriched.json');
42
+ const here = dirname(fileURLToPath(import.meta.url));
43
+
44
+ const rows = existsSync(ledgerPath)
45
+ ? readFileSync(ledgerPath, 'utf8').split('\n').filter(Boolean)
46
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
47
+ .filter(Boolean)
48
+ : [];
49
+
50
+ const titles = {};
51
+ const planModule = {};
52
+ if (existsSync(planPath)) {
53
+ try {
54
+ const plan = JSON.parse(readFileSync(planPath, 'utf8'));
55
+ for (const v of (plan.videos || [])) {
56
+ if (v.index == null) continue;
57
+ titles[String(v.index)] = v.title;
58
+ planModule[String(v.index)] = v.group || ((v.sources && v.sources[0]) || '').split('/')[0]; // a code-flow plan groups by flow
59
+ }
60
+ } catch { /* plan unreadable — fall back to the raw key and a single module */ }
61
+ }
62
+ const titleOf = (video) => titles[video] || video;
63
+ const round1 = (n) => String(Math.round(n * 10) / 10);
64
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
65
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
+ const fmtDur = (n) => n < 60 ? `${round1(n)}s` : `${Math.floor(n / 60)}m${Math.round(n % 60)}s`;
67
+
68
+ const STAGE_NAMES = { '3': 'script', '4': 'audio', '4b': 'align', '5': 'scene', '6': 'publish', '7': 'render', '8': 'assemble' };
69
+ const PALETTE = ['#4f46e5', '#0ea5e9', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6',
70
+ '#ec4899', '#10b981', '#f97316', '#6366f1', '#a855f7', '#22c55e'];
71
+
72
+ /* Module display names: "09-tool-platforms-mcp" → "Tool Platforms MCP". */
73
+ const ACRONYM = { mcp: 'MCP', llm: 'LLM', llms: 'LLMs', llmops: 'LLMOps', adk: 'ADK', api: 'API', rag: 'RAG', gcp: 'GCP', ui: 'UI' };
74
+ const displayName = (mod) => {
75
+ const s = String(mod || '').replace(/^\d+[-_]/, '');
76
+ if (!s) return '(ungrouped)';
77
+ return s.split(/[-_]/).filter(Boolean)
78
+ .map((w) => ACRONYM[w.toLowerCase()] || (w.charAt(0).toUpperCase() + w.slice(1)))
79
+ .join(' ');
80
+ };
81
+
82
+ const order = [...new Set(rows.map((r) => r.video))];
83
+ order.sort((a, b) => {
84
+ const na = parseInt(a, 10), nb = parseInt(b, 10);
85
+ if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
86
+ return a < b ? -1 : a > b ? 1 : 0;
87
+ });
88
+
89
+ /* Per video: its stages (attempts summed), total, and plan index. */
90
+ const vids = order.map((video) => {
91
+ const byStage = new Map();
92
+ for (const r of rows) {
93
+ if (r.video !== video) continue;
94
+ const e = byStage.get(r.stage) || { s: 0, n: 0 };
95
+ e.s += r.seconds; e.n += 1; byStage.set(r.stage, e);
96
+ }
97
+ const stages = [...byStage.entries()].map(([st, e]) => [st, e.s, e.n]);
98
+ return {
99
+ video, index: parseInt(video, 10),
100
+ title: titleOf(video),
101
+ stages,
102
+ total: stages.reduce((a, [, s]) => a + s, 0),
103
+ };
104
+ });
105
+
106
+ /* Stage order across the whole ledger (first-seen = pipeline order). */
107
+ const stageOrder = [];
108
+ for (const v of vids) for (const [st] of v.stages) if (!stageOrder.includes(st)) stageOrder.push(st);
109
+ const colorOf = new Map(stageOrder.map((st, i) => [st, PALETTE[i % PALETTE.length]]));
110
+ const stageLabel = (st) => STAGE_NAMES[st] ? `${st} · ${STAGE_NAMES[st]}` : st;
111
+
112
+ /* Group by module. A video with no plan entry lands in '(ungrouped)'. */
113
+ const byModule = new Map();
114
+ for (const v of vids) {
115
+ const mod = planModule[v.video] || '';
116
+ if (!byModule.has(mod)) byModule.set(mod, []);
117
+ byModule.get(mod).push(v);
118
+ }
119
+ const modules = [...byModule.entries()]
120
+ .sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))
121
+ .map(([name, videos]) => ({ name, display: displayName(name), videos }));
122
+
123
+ const mode = (forceFlat || modules.length <= 1) ? 'flat' : 'grouped';
124
+ const maxTotal = Math.max(1, ...vids.map((v) => v.total));
125
+
126
+ /* ---- CSV (long format) ---- */
127
+ const csvField = (s) => {
128
+ s = s == null ? '' : String(s);
129
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
130
+ };
131
+ let csv = 'video_title,stage,attempt,seconds,ended_at,note\n';
132
+ for (const r of rows) {
133
+ csv += [titleOf(r.video), r.stage, r.attempt, r.seconds, r.ended_at, r.note ?? '']
134
+ .map(csvField).join(',') + '\n';
135
+ }
136
+ writeFileSync(join(outDir, 'timing.csv'), csv);
137
+
138
+ /* ---- MD (grouped by video) ---- */
139
+ let md = '# Timing audit\n\n';
140
+ if (!rows.length) md += '_No timing rows yet — run stages 3–8 and stamp them._\n';
141
+ for (const v of vids) {
142
+ md += `## ${v.title}\n\n| stage | seconds | attempts |\n| --- | --- | --- |\n`;
143
+ let total = 0;
144
+ for (const [s, e, n] of v.stages) { md += `| ${s} | ${round1(e)} | ${n} |\n`; total += e; }
145
+ md += `| **total** | **${round1(total)}** | |\n\n`;
146
+ }
147
+ writeFileSync(join(outDir, 'timing.md'), md);
148
+
149
+ /* ---- SVG (static stacked bars, no-JS fallback) ---- */
150
+ function stageTotals(videos) {
151
+ const byStage = new Map();
152
+ for (const v of videos) {
153
+ for (const [st, sec, n] of v.stages) {
154
+ const e = byStage.get(st) || { s: 0, n: 0 };
155
+ e.s += sec; e.n += n; byStage.set(st, e);
156
+ }
157
+ }
158
+ const stages = [...byStage.entries()].map(([st, e]) => [st, e.s, e.n]);
159
+ return { stages, total: stages.reduce((a, [, s]) => a + s, 0) };
160
+ }
161
+
162
+ function buildSvg(list, title) {
163
+ const L = 20, LABEL = 360, GAP = 12, CHART = 560, RAXIS = 64, R = 16;
164
+ const WIDTH = L + LABEL + GAP + CHART + RAXIS + R;
165
+ const ROW = 26, ROWGAP = 10;
166
+ const TITLE_H = 30, LEGEND_H = 22, AXIS_H = 26;
167
+ const max = Math.max(1, ...list.map((x) => x.total));
168
+
169
+ let legendLines = 1, lx = 0;
170
+ for (const st of stageOrder) {
171
+ const w = 16 + 6 + stageLabel(st).length * 7.2;
172
+ if (lx + w > WIDTH - L - R && lx > 0) { legendLines++; lx = 0; }
173
+ lx += w + 16;
174
+ }
175
+
176
+ const top = 20;
177
+ const legendTop = top + TITLE_H + 6;
178
+ const chartTop = legendTop + legendLines * LEGEND_H + 14;
179
+ const rowsTop = chartTop + 8;
180
+ const axisTop = rowsTop + list.length * (ROW + ROWGAP);
181
+ const HEIGHT = axisTop + AXIS_H + 16;
182
+ const chartX = L + LABEL + GAP;
183
+
184
+ const p = [];
185
+ p.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" `
186
+ + `viewBox="0 0 ${WIDTH} ${HEIGHT}" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">`);
187
+ p.push(`<rect width="100%" height="100%" fill="#ffffff"/>`);
188
+ p.push(`<text x="${L}" y="${top + 18}" font-size="18" font-weight="600" fill="#111827">${esc(title)}</text>`);
189
+
190
+ if (!list.length) {
191
+ p.push(`<text x="${L}" y="${chartTop + 24}" font-size="14" fill="#6b7280">No timing rows yet.</text>`);
192
+ p.push('</svg>');
193
+ return p.join('\n');
194
+ }
195
+
196
+ lx = L; let ly = legendTop;
197
+ for (const st of stageOrder) {
198
+ const w = 16 + 6 + stageLabel(st).length * 7.2;
199
+ if (lx + w > WIDTH - R && lx > L) { lx = L; ly += LEGEND_H; }
200
+ p.push(`<rect x="${lx}" y="${ly}" width="14" height="14" rx="3" fill="${colorOf.get(st)}"/>`);
201
+ p.push(`<text x="${lx + 20}" y="${ly + 11}" font-size="12" fill="#374151">${esc(stageLabel(st))}</text>`);
202
+ lx += w + 16;
203
+ }
204
+
205
+ const ticks = 5;
206
+ for (let i = 0; i <= ticks; i++) {
207
+ const gx = chartX + CHART * i / ticks;
208
+ p.push(`<line x1="${gx}" y1="${rowsTop - 8}" x2="${gx}" y2="${axisTop - AXIS_H}" stroke="#e5e7eb"/>`);
209
+ const anchor = i === 0 ? 'start' : i === ticks ? 'end' : 'middle';
210
+ p.push(`<text x="${gx}" y="${axisTop}" font-size="11" fill="#6b7280" text-anchor="${anchor}">`
211
+ + `${fmtDur(max * i / ticks)}</text>`);
212
+ }
213
+
214
+ list.forEach((v, i) => {
215
+ const y = rowsTop + i * (ROW + ROWGAP);
216
+ const label = v.title.length > 52 ? v.title.slice(0, 51) + '…' : v.title;
217
+ p.push(`<text x="${L}" y="${y + ROW / 2 + 4}" font-size="12" fill="#111827">${esc(label)}</text>`);
218
+ let bx = chartX;
219
+ for (const [st, sec, attempts] of v.stages) {
220
+ const w = (sec / max) * CHART;
221
+ const tip = `${esc(v.title)} — ${esc(stageLabel(st))}: ${round1(sec)}s${attempts > 1 ? ' (' + attempts + ' attempts)' : ''}`;
222
+ p.push(`<rect x="${bx}" y="${y}" width="${w}" height="${ROW}" rx="2" fill="${colorOf.get(st)}">`
223
+ + `<title>${tip}</title></rect>`);
224
+ bx += w;
225
+ }
226
+ p.push(`<text x="${chartX + (v.total / max) * CHART + 8}" y="${y + ROW / 2 + 4}" `
227
+ + `font-size="11" fill="#374151">${fmtDur(v.total)}</text>`);
228
+ });
229
+
230
+ p.push('</svg>');
231
+ return p.join('\n');
232
+ }
233
+
234
+ const svgList = mode === 'grouped'
235
+ ? modules.map((m) => ({ title: m.display, ...stageTotals(m.videos) }))
236
+ : vids.map((v) => ({ title: v.title, stages: v.stages, total: v.total }));
237
+ const svgTitle = mode === 'grouped' ? 'Time per module, by stage' : 'Time per video, by stage';
238
+ writeFileSync(join(outDir, 'timing.svg'), buildSvg(svgList, svgTitle));
239
+
240
+ /* ---- HTML (interactive Plotly, self-contained + offline) ---- */
241
+ function buildHtml() {
242
+ if (!vids.length) {
243
+ return '<!doctype html><html><head><meta charset="utf-8"></head>'
244
+ + '<body style="font-family:system-ui,sans-serif"><p>No timing rows yet.</p></body></html>';
245
+ }
246
+ const vendor = readFileSync(join(here, '..', 'vendor', 'plotly-basic.min.js'), 'utf8');
247
+ const chartJs = readFileSync(join(here, '..', 'src', 'timing-chart.js'), 'utf8');
248
+
249
+ const htmlModules = modules.map((m) => ({
250
+ name: m.name,
251
+ display: m.display,
252
+ videos: m.videos.map((v) => ({
253
+ index: v.index,
254
+ title: v.title,
255
+ stages: Object.fromEntries(v.stages.map(([st, sec]) => [st, sec])),
256
+ })),
257
+ }));
258
+ // escaping "</" keeps a title containing "</script>" from closing the tag
259
+ const data = JSON.stringify({ mode, stages: stageOrder.map((k) => [k, STAGE_NAMES[k] || '']), modules: htmlModules })
260
+ .replace(/<\//g, '<\\/');
261
+
262
+ const body = mode === 'grouped'
263
+ ? `<h1>Time per module</h1>
264
+ <div id="timing-chart"></div>
265
+ <p class="hint">Click a module bar to break it down by video.</p>
266
+ <div id="timing-detail-wrap" hidden>
267
+ <h2 id="timing-detail-title"></h2>
268
+ <p class="hint"><a href="#" id="all-modules">&uarr; all modules</a></p>
269
+ <div id="timing-detail"></div>
270
+ </div>`
271
+ : `<h1>Time per video, by stage</h1>
272
+ <div id="timing-chart"></div>`;
273
+
274
+ return `<!doctype html>
275
+ <html><head><meta charset="utf-8"><title>timing audit</title>
276
+ <style>
277
+ body { margin:0; padding:0 16px 40px; background:#fff; color:#111827;
278
+ font-family:system-ui,-apple-system,'Segoe UI',sans-serif; }
279
+ #timing-chart, #timing-detail { width:100%; }
280
+ h1 { font-size:17px; font-weight:600; margin:14px 0 0; }
281
+ h2 { font-size:15px; font-weight:600; margin:22px 0 0; }
282
+ .hint { font-size:11px; color:#6b7280; margin:6px 0 0; }
283
+ .hint a { color:#4f46e5; text-decoration:none; }
284
+ .hint a:hover { text-decoration:underline; }
285
+ </style>
286
+ </head><body>
287
+ ${body}
288
+ <script>${vendor}</script>
289
+ <script>window.TIMING_DATA = ${data};</script>
290
+ <script>${chartJs}</script>
291
+ </body></html>`;
292
+ }
293
+ writeFileSync(join(outDir, 'timing.html'), buildHtml());
294
+
295
+ console.log(`[timing] ${rows.length} row(s) · ${order.length} video(s) · ${modules.length} module(s) · ${mode}`);
296
+ console.log(`[out] ${join(outDir, 'timing.csv')}`);
297
+ console.log(`[out] ${join(outDir, 'timing.md')}`);
298
+ console.log(`[out] ${join(outDir, 'timing.svg')}`);
299
+ console.log(`[out] ${join(outDir, 'timing.html')}`);
@@ -0,0 +1,96 @@
1
+ /* =============================================================================
2
+ transcribe-gemini.mjs — word-level timings for a narration WAV.
3
+
4
+ Gemini's TTS models return audio only (verified: the response has no timing
5
+ fields at all). But `gemini-3.5-transcribe` can return per-word offsets when
6
+ asked for them — the flag is `wordTimestamp`, which the API discovery schema
7
+ documents but the prose docs never show in an example:
8
+
9
+ generationConfig.audioTranscriptionConfig.wordTimestamp = true
10
+ -> candidates[0].content.parts[0].audioTranscription.words[]
11
+ { word, startOffset: "3.900s", endOffset: "4.100s" }
12
+
13
+ Two things this module deliberately does NOT do:
14
+ • customVocabulary — the API rejects it together with wordTimestamp.
15
+ • trust the transcript as text. It applies inverse text normalisation
16
+ ("forty" -> "40") and rewrites punctuation, so callers must align the
17
+ returned words back onto their own known text. See align.mjs.
18
+
19
+ Audio is sent inline (base64) — no Files API upload, one request per segment.
20
+ ========================================================================== */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { loadKey, scrub } from './key-source.mjs';
24
+
25
+ const API = 'https://generativelanguage.googleapis.com/v1beta';
26
+ export const MODEL = 'gemini-3.5-transcribe';
27
+
28
+ /** "12.600s" | "1.5s" -> 12.6 | 1.5 (null when absent/unparseable) */
29
+ export function parseOffset(d) {
30
+ if (typeof d === 'number') return d;
31
+ const m = /^(-?\d+(?:\.\d+)?)s$/.exec(String(d ?? '').trim());
32
+ return m ? parseFloat(m[1]) : null;
33
+ }
34
+
35
+ /**
36
+ * Transcribe one WAV, returning word records with float offsets in seconds.
37
+ *
38
+ * @param {{wavPath:string, key?:string, languageCode?:string, retries?:number}} opts
39
+ * @returns {Promise<{text:string, words:Array<{w:string,s:number,e:number}>, model:string}>}
40
+ */
41
+ export async function transcribeWords({ wavPath, key, languageCode = 'en-US', retries = 3 }) {
42
+ const apiKey = key || loadKey().key;
43
+ if (!apiKey) throw new Error('No API key found — see key-source.mjs for the store command.');
44
+
45
+ const b64 = readFileSync(wavPath).toString('base64');
46
+ const body = {
47
+ contents: [{ parts: [{ inlineData: { mimeType: 'audio/wav', data: b64 } }] }],
48
+ generationConfig: {
49
+ audioTranscriptionConfig: { languageCodes: [languageCode], wordTimestamp: true },
50
+ },
51
+ };
52
+
53
+ for (let attempt = 0; ; attempt++) {
54
+ const res = await fetch(`${API}/models/${MODEL}:generateContent`, {
55
+ method: 'POST',
56
+ headers: { 'x-goog-api-key': apiKey, 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(body),
58
+ });
59
+ const text = await res.text();
60
+
61
+ if (res.status === 429 && attempt < retries) {
62
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
63
+ continue;
64
+ }
65
+ if (!res.ok) {
66
+ let msg = text.slice(0, 300);
67
+ try { msg = JSON.parse(text).error?.message || msg; } catch {}
68
+ throw new Error(`transcribe failed ${res.status}: ${scrub(msg)}`);
69
+ }
70
+
71
+ let json;
72
+ try { json = JSON.parse(text); } catch { throw new Error('transcribe returned non-JSON'); }
73
+ const part = json.candidates?.[0]?.content?.parts?.[0] || {};
74
+ const at = part.audioTranscription;
75
+ if (!at) {
76
+ throw new Error(
77
+ 'transcribe returned no audioTranscription block — the model may have refused this audio.\n' +
78
+ ` parts: ${JSON.stringify(Object.keys(part))}`
79
+ );
80
+ }
81
+
82
+ const words = (at.words || []).map(w => ({
83
+ w: String(w.word ?? ''),
84
+ s: parseOffset(w.startOffset),
85
+ e: parseOffset(w.endOffset),
86
+ })).filter(w => w.s != null && w.e != null);
87
+
88
+ if (!words.length) {
89
+ throw new Error(
90
+ 'transcribe returned words[] empty — wordTimestamp was set but no offsets came back.\n' +
91
+ ' Refusing to write a timing-less alignment (silent desync is worse than a hard failure).'
92
+ );
93
+ }
94
+ return { text: String(at.text ?? ''), words, model: MODEL };
95
+ }
96
+ }