explainer-video 0.0.0-stage → 0.4.1

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 (153) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -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 +70 -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/craft-video-thumbnail/SKILL.md +168 -0
  24. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  25. package/skills/explainer-video-from-codebase/SKILL.md +698 -0
  26. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  27. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  28. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  29. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  30. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  31. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  32. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  33. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  35. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  37. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  38. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  45. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  59. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  60. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  61. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  62. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  63. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  81. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  88. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  89. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  106. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  107. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +61 -0
  108. package/skills/explainer-video-from-coursework/PIPELINE.md +369 -0
  109. package/skills/explainer-video-from-coursework/ROADMAP.md +727 -0
  110. package/skills/explainer-video-from-coursework/SERIES.md +243 -0
  111. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  112. package/skills/explainer-video-from-coursework/SKILL.md +998 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  118. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  120. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  121. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  122. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  124. package/skills/explainer-video-from-coursework/src/thumbnail.html +119 -0
  125. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  126. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  127. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  128. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  129. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  130. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  131. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  132. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  133. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  134. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  135. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  136. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  137. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  138. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  139. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  140. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  141. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  142. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  143. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  144. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  145. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  146. package/skills/explainer-video-from-coursework/tools/thumbnail.mjs +224 -0
  147. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  148. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  149. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  150. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  151. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  152. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  153. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,394 @@
1
+ // Google Gemini TTS backend — no npm dependencies (Node 22 global fetch).
2
+ //
3
+ // node tools/tts-gemini.mjs --probe
4
+ // node tools/tts-gemini.mjs --text "Hello there" --voice Kore --style "warm, conversational" --out out.wav
5
+ // node tools/tts-gemini.mjs --voices
6
+ //
7
+ // Auth (first hit wins, no key is ever logged or echoed):
8
+ // 1. --key <value> explicit CLI arg — transient, visible in ps
9
+ // 2. the OS secret store macOS Keychain · Linux secret-tool · Windows Credential Manager
10
+ //
11
+ // Model: gemini-3.8-flash-tts (Interactions API)
12
+ // POST https://generativelanguage.googleapis.com/v1beta/interactions
13
+ // Response: interaction.output_audio.data -> base64 WAV
14
+ //
15
+ // TTS is "controllable": `speech_metadata.style` steers style, accent, pace, tone.
16
+
17
+ import { writeFileSync, readFileSync } from 'node:fs';
18
+ import { loadKey, scrub, describe, storeHint } from './key-source.mjs';
19
+
20
+ export const API = 'https://generativelanguage.googleapis.com/v1beta';
21
+ export const DEFAULT_MODEL = 'gemini-3.8-flash-tts';
22
+ export const FALLBACK_MODEL = 'gemini-3.8-flash-lite-tts';
23
+
24
+ // Prebuilt voice options (from the Gemini TTS docs).
25
+ export const VOICES = [
26
+ 'Kore', 'Puck', 'Charon', 'Fenrir', 'Aoede', 'Leda', 'Orus', 'Zephyr',
27
+ 'Callirrhoe', 'Autonoe', 'Enceladus', 'Iapetus', 'Umbriel', 'Algieba',
28
+ 'Despina', 'Erinome', 'Algenib', 'Rasalgethi', 'Laomedeia', 'Achernar',
29
+ 'Alnilam', 'Schedar', 'Gacrux', 'Pulcherrima', 'Achird', 'Zubenelgenubi',
30
+ 'Vindemiatrix', 'Sadachbia', 'Sadaltager', 'Sulafat',
31
+ ];
32
+
33
+ let lastSource = 'none';
34
+
35
+ function apiKey(explicit) {
36
+ const { key, source, store } = loadKey(explicit);
37
+ lastSource = source;
38
+ if (!key) {
39
+ throw new Error(
40
+ 'No API key found. Store it in your OS secret store, then re-run.\n' +
41
+ storeHint(store) +
42
+ ' One-off override: pass --key <value> (visible in the process list).\n' +
43
+ ' Free key: https://aistudio.google.com/apikey'
44
+ );
45
+ }
46
+ return key;
47
+ }
48
+
49
+ /** Scrub anything key-shaped out of an error message before it is shown. */
50
+ const safe = msg => scrub(msg);
51
+
52
+ function errMessage(json, text) {
53
+ // Google returns either {error:{message}} or [{error:{message}}]
54
+ const j = Array.isArray(json) ? json[0] : json;
55
+ return scrub(j?.error?.message || text.slice(0, 400).replace(/\s+/g, ' '));
56
+ }
57
+
58
+ /**
59
+ * Locate the audio payload in an Interactions response.
60
+ *
61
+ * The SDK exposes a convenience field `output_audio`, but the raw REST body
62
+ * nests it as a model_output step:
63
+ * steps[i].content[j] = { type:"audio", mime_type:"audio/wav", data:"<base64>" }
64
+ * Handle the SDK shape too, in case it is ever present.
65
+ */
66
+ function extractAudio(json) {
67
+ const direct = json?.output_audio || json?.outputAudio;
68
+ if (direct?.data) return { data: direct.data, mime: direct.mime_type || 'audio/wav' };
69
+
70
+ for (const step of json?.steps || []) {
71
+ for (const c of step?.content || []) {
72
+ const isAudio = c?.type === 'audio' || String(c?.mime_type || '').startsWith('audio/');
73
+ if (c?.data && isAudio) return { data: c.data, mime: c.mime_type || 'audio/wav' };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
80
+
81
+ /**
82
+ * POST/GET with automatic retry, but ONLY for limits that can plausibly clear.
83
+ *
84
+ * Free tier has BOTH:
85
+ * • RPM — "3 requests per minute" -> retry after Ns, works
86
+ * • RPD — "10 requests per day" -> retry after Nh, futile; fail fast
87
+ *
88
+ * Retrying an RPD exhaustion just burns wall-clock for nothing, so detect it
89
+ * from the message and abort immediately with an actionable error.
90
+ */
91
+ async function call(path, body, key, method = 'POST', { retries = 4 } = {}) {
92
+ for (let attempt = 0; ; attempt++) {
93
+ const res = await fetch(`${API}${path}`, {
94
+ method,
95
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
96
+ ...(body ? { body: JSON.stringify(body) } : {}),
97
+ });
98
+ const text = await res.text();
99
+ let json = null;
100
+ try { json = JSON.parse(text); } catch {}
101
+
102
+ if (res.status === 429) {
103
+ const msg = errMessage(json, text);
104
+
105
+ // Daily quota: not worth waiting for.
106
+ if (/per day/i.test(msg) || /retry in\s*\d+\s*h/i.test(msg)) {
107
+ const eta = /retry in\s*([0-9hm\s.]+)/i.exec(msg)?.[1]?.trim() || 'unknown';
108
+ const err = new Error(
109
+ `Daily quota exhausted (${eta} until reset, ~midnight Pacific).\n` +
110
+ ` ${msg}\n` +
111
+ ` Options: wait for the reset, enable billing (Tier 1 raises limits; ` +
112
+ `this whole build costs ~2 cents), or use a different model which has its own quota:\n` +
113
+ ` --model gemini-3.8-flash-lite-tts\n` +
114
+ ` --model gemini-2.5-flash-preview-tts`
115
+ );
116
+ err.daily = true;
117
+ throw err;
118
+ }
119
+
120
+ // Per-minute throttle: waiting genuinely helps.
121
+ if (attempt < retries) {
122
+ const m = /retry in\s*(\d+(?:\.\d+)?)\s*s/i.exec(msg);
123
+ const wait = m ? Math.ceil(parseFloat(m[1])) + 3 : 25 * (attempt + 1);
124
+ process.stderr.write(` [rate limit] per-minute 429 — waiting ${wait}s (retry ${attempt + 1}/${retries})\n`);
125
+ await sleep(wait * 1000);
126
+ continue;
127
+ }
128
+ }
129
+ return { status: res.status, ok: res.ok, json, text };
130
+ }
131
+ }
132
+
133
+ /** Synthesize one segment -> WAV buffer. */
134
+ export async function synthesize({
135
+ text,
136
+ voice = 'Kore',
137
+ style = 'warm, natural, conversational — like explaining to a colleague',
138
+ model = DEFAULT_MODEL,
139
+ key,
140
+ speechConfig,
141
+ } = {}) {
142
+ const k = apiKey(key);
143
+
144
+ // A voice id from voice replication / voice design is passed through as-is;
145
+ // a prebuilt voice is passed by name. Both go in speech_config[].voice.
146
+ const voiceEntry = speechConfig || { voice };
147
+
148
+ const body = {
149
+ model,
150
+ input: [{
151
+ type: 'user_input',
152
+ content: [{
153
+ type: 'text',
154
+ text,
155
+ annotations: style ? [{ type: 'speech_metadata', style }] : [],
156
+ }],
157
+ }],
158
+ response_format: { type: 'audio' },
159
+ generation_config: { speech_config: [voiceEntry] },
160
+ };
161
+
162
+ const r = await call('/interactions', body, k);
163
+ if (!r.ok) {
164
+ const msg = errMessage(r.json, r.text);
165
+ throw new Error(`Gemini TTS ${r.status}: ${msg}`);
166
+ }
167
+ const audio = extractAudio(r.json);
168
+ if (!audio) {
169
+ throw new Error('No audio in response. Top-level keys: ' +
170
+ Object.keys(r.json || {}).join(', '));
171
+ }
172
+ const wav = Buffer.from(audio.data, 'base64');
173
+ return { wav, mime: audio.mime, usage: r.json?.usage || null, raw: r.json };
174
+ }
175
+
176
+ /* ---- Batch TTS (generateContent + batchGenerateContent) ----
177
+ Batch is token-metered, not request-metered, so it sidesteps the per-day RPD
178
+ wall. It is generateContent-only and uses a DIFFERENT voice schema than the
179
+ Interactions API: speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName. */
180
+
181
+ export function genTtsRequest(text, style, voice) {
182
+ const part = { text };
183
+ if (style) part.speech_metadata = { style };
184
+ return {
185
+ contents: [{ role: 'user', parts: [part] }],
186
+ generationConfig: {
187
+ responseModalities: ['AUDIO'],
188
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } },
189
+ },
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Synthesize many styled segments in one batch job. Returns the segments that
195
+ * produced audio, so a partial success still yields something.
196
+ *
197
+ * Fallback ladder: on a quota/transient failure the *remaining* segments spill
198
+ * onto `fallbackModel` — the "exhaust one model's daily budget, then the next"
199
+ * behaviour. The whole job is never silently downgraded; only the leftovers are.
200
+ *
201
+ * @returns {Promise<{segments: Array<{key, wav}>, jobs: number}>}
202
+ */
203
+ export async function synthesizeBatch({
204
+ segments, // [{ key, text, style }]
205
+ voice = 'Kore',
206
+ model = DEFAULT_MODEL,
207
+ fallbackModel = FALLBACK_MODEL,
208
+ key,
209
+ pollMs = 15000,
210
+ timeoutMs = 30 * 60 * 1000,
211
+ } = {}) {
212
+ const k = apiKey(key);
213
+ const done = new Map(); // key -> WAV Buffer
214
+
215
+ const submit = async (m, segs) => {
216
+ const body = {
217
+ batch: {
218
+ display_name: 'narration',
219
+ input_config: {
220
+ requests: {
221
+ requests: segs.map(s => ({ request: genTtsRequest(s.text, s.style, voice), metadata: { key: s.key } })),
222
+ },
223
+ },
224
+ },
225
+ };
226
+ const res = await fetch(`${API}/models/${m}:batchGenerateContent`, {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': k },
229
+ body: JSON.stringify(body),
230
+ });
231
+ const text = await res.text();
232
+ let json = null; try { json = JSON.parse(text); } catch {}
233
+ if (!res.ok) {
234
+ const msg = scrub(json?.error?.message || text).slice(0, 300).replace(/\s+/g, ' ');
235
+ const err = new Error(`batch ${res.status}: ${msg}`);
236
+ err.quota = /quota|per day|RESOURCE_EXHAUSTED|exhausted/i.test(msg);
237
+ err.transient = err.quota || res.status === 429 || res.status === 503;
238
+ throw err;
239
+ }
240
+ const name = json?.name || json?.metadata?.name;
241
+ if (!name) throw new Error('batch submit returned no job name');
242
+ return name;
243
+ };
244
+
245
+ const poll = async (name) => {
246
+ const deadline = Date.now() + timeoutMs;
247
+ let last = null;
248
+ for (;;) {
249
+ if (Date.now() > deadline) {
250
+ const e = new Error(`batch job not finished after ${Math.round(timeoutMs / 60000)} min (${name})`);
251
+ e.timeout = true; throw e;
252
+ }
253
+ await sleep(pollMs);
254
+ const res = await fetch(`${API}/${name}`, { headers: { 'x-goog-api-key': k } });
255
+ const gj = await res.json();
256
+ const state = gj?.state || gj?.metadata?.state;
257
+ if (state !== last) { process.stderr.write(` [batch] ${state}\n`); last = state; }
258
+ if (state === 'BATCH_STATE_SUCCEEDED') return gj;
259
+ if (state === 'BATCH_STATE_FAILED' || state === 'BATCH_STATE_CANCELLED') {
260
+ throw new Error(`batch job ${state}`);
261
+ }
262
+ }
263
+ };
264
+
265
+ /* Map each inlined response back to its segment (by echoed metadata key, else
266
+ by request order) and stash any audio. Returns the segments still missing. */
267
+ const harvest = (job, segs) => {
268
+ const list = job?.metadata?.output?.inlinedResponses?.inlinedResponses
269
+ || job?.output?.inlinedResponses?.inlinedResponses || [];
270
+ list.forEach((item, i) => {
271
+ const parts = item?.response?.candidates?.[0]?.content?.parts || [];
272
+ const audio = parts.find(p => (p?.inlineData?.mimeType || '').startsWith('audio'));
273
+ if (!audio) return;
274
+ const mk = item?.metadata?.key;
275
+ const seg = mk ? segs.find(s => s.key === mk) : segs[i];
276
+ if (seg) done.set(seg.key, Buffer.from(audio.inlineData.data, 'base64'));
277
+ });
278
+ return segs.filter(s => !done.has(s.key));
279
+ };
280
+
281
+ const rungs = [model];
282
+ if (fallbackModel && fallbackModel !== model) rungs.push(fallbackModel);
283
+
284
+ let pending = segments.slice();
285
+ let jobs = 0;
286
+ for (const m of rungs) {
287
+ if (!pending.length) break;
288
+ let failed = [];
289
+ try {
290
+ const name = await submit(m, pending);
291
+ jobs++;
292
+ failed = harvest(await poll(name), pending);
293
+ } catch (e) {
294
+ if (!e.transient && !e.timeout && !/BATCH_STATE_FAILED/.test(e.message)) throw e;
295
+ process.stderr.write(` [batch] ${m} unavailable — ${e.message.split('\n')[0]}\n`);
296
+ failed = pending.slice(); // retry everything on the next rung
297
+ }
298
+ pending = failed;
299
+ }
300
+
301
+ if (pending.length) {
302
+ throw new Error(`[batch] ${pending.length} segment(s) still lack audio after all models: ${pending.map(s => s.key).join(', ')}`);
303
+ }
304
+ return {
305
+ segments: segments.filter(s => done.has(s.key)).map(s => ({ key: s.key, wav: done.get(s.key) })),
306
+ jobs,
307
+ };
308
+ }
309
+
310
+ /** List voices available to this project (prebuilt + extended library + custom). */
311
+ export async function listVoices(key) {
312
+ const r = await call('/voices', null, apiKey(key), 'GET');
313
+ if (!r.ok) throw new Error(`voices ${r.status}: ${errMessage(r.json, r.text)}`);
314
+ return r.json;
315
+ }
316
+
317
+ /** Voice replication: reference audio + consent audio -> voice id. */
318
+ export async function replicateVoice({ referenceWav, consentWav, displayName = 'Replicated Voice', model = DEFAULT_MODEL, store = true, key } = {}) {
319
+ const k = apiKey(key);
320
+ const b64 = p => (Buffer.isBuffer(p) ? p : readFileSync(p)).toString('base64');
321
+ const r = await call('/voices', {
322
+ store,
323
+ voice: {
324
+ model,
325
+ type: 'replicated',
326
+ display_name: displayName,
327
+ replicated: {
328
+ source_audio: { mime_type: 'audio/wav', data: b64(referenceWav) },
329
+ consent_audio: { mime_type: 'audio/wav', data: b64(consentWav) },
330
+ },
331
+ },
332
+ }, k);
333
+ if (!r.ok) throw new Error(`replicate ${r.status}: ${errMessage(r.json, r.text)}`);
334
+ return r.json;
335
+ }
336
+
337
+ /* ---------------- CLI ---------------- */
338
+ const isMain = process.argv[1] && process.argv[1].endsWith('tts-gemini.mjs');
339
+ if (isMain) {
340
+ const argv = process.argv.slice(2);
341
+ const flag = (n, d = null) => {
342
+ const i = argv.indexOf('--' + n);
343
+ return i === -1 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true);
344
+ };
345
+
346
+ try {
347
+ if (flag('probe')) {
348
+ const k = apiKey(flag('key'));
349
+ console.log(`[auth] key source: ${describe(lastSource, k)}`); // never the key itself
350
+ console.log('[probe] POST /v1beta/interactions (expect 200 with a valid key)\n');
351
+ const r = await call('/interactions', {
352
+ model: DEFAULT_MODEL,
353
+ input: [{ type: 'user_input', content: [{ type: 'text', text: 'Testing one two three.',
354
+ annotations: [{ type: 'speech_metadata', style: 'neutral' }] }] }],
355
+ response_format: { type: 'audio' },
356
+ generation_config: { speech_config: [{ voice: 'Kore' }] },
357
+ }, k);
358
+ console.log(' status:', r.status);
359
+ if (r.ok) {
360
+ const a = extractAudio(r.json);
361
+ if (a) {
362
+ const n = Buffer.from(a.data, 'base64').length;
363
+ console.log(` AUDIO OK — ${n} bytes, ${a.mime}`);
364
+ if (r.json.usage?.total_output_tokens != null) {
365
+ console.log(` output tokens: ${r.json.usage.total_output_tokens}`);
366
+ }
367
+ } else {
368
+ console.log(' no audio found. keys:', Object.keys(r.json || {}).join(', '));
369
+ }
370
+ } else {
371
+ console.log(' body:', scrub(r.json?.error?.message || r.text).slice(0, 400));
372
+ }
373
+ } else if (flag('voices')) {
374
+ const v = await listVoices(flag('key'));
375
+ console.log(JSON.stringify(v, null, 2).slice(0, 2000));
376
+ } else if (flag('text')) {
377
+ const { wav } = await synthesize({
378
+ text: String(flag('text')),
379
+ voice: flag('voice', 'Kore'),
380
+ style: flag('style', undefined),
381
+ model: flag('model', DEFAULT_MODEL),
382
+ key: flag('key'),
383
+ });
384
+ const out = flag('out', 'tts-out.wav');
385
+ writeFileSync(out, wav);
386
+ console.log(`[ok] ${out} ${(wav.length / 1024).toFixed(0)} KB`);
387
+ } else {
388
+ console.log('usage: --probe | --voices | --text "..." [--voice Kore] [--style "..."] [--out f.wav]');
389
+ }
390
+ } catch (e) {
391
+ console.error('[error]', scrub(e.message));
392
+ process.exit(1);
393
+ }
394
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /* =============================================================================
3
+ verify-audio.mjs — does the finished video's audio actually match the
4
+ narration track, sample for sample?
5
+
6
+ RMS was not good enough. Speech sits at a roughly constant level, so a part
7
+ playing the WRONG slice of narration can still have a similar RMS. The only
8
+ reliable question is: if you subtract one from the other, how much energy is
9
+ left?
10
+
11
+ residual = sqrt( sum((a-n)^2) / sum(a^2) )
12
+
13
+ Matching content -> residual is near zero (only codec noise).
14
+ Wrong slice -> residual approaches ~1.4 (two unrelated signals).
15
+
16
+ Usage:
17
+ node tools/verify-audio.mjs --video out/<name>/<NN>/<output>.mp4 \
18
+ --narration out/<name>/<NN>/narration.wav [--max 0.25]
19
+ ========================================================================== */
20
+
21
+ import { execFileSync } from 'node:child_process';
22
+ import { readFileSync, rmSync, existsSync, mkdtempSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { tmpdir } from 'node:os';
25
+ import { requireCmd } from './env.mjs';
26
+
27
+ requireCmd('ffmpeg');
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];
33
+ };
34
+ const VIDEO = flag('video');
35
+ const NARR = flag('narration');
36
+ const MAX = parseFloat(flag('max', '0.25'));
37
+ const SR = 22050;
38
+
39
+ if (!VIDEO || !NARR || !existsSync(VIDEO) || !existsSync(NARR)) {
40
+ console.error('usage: node tools/verify-audio.mjs --video <mp4> --narration <wav> [--max 0.25]');
41
+ process.exit(1);
42
+ }
43
+
44
+ const dir = mkdtempSync(join(tmpdir(), 'va-'));
45
+ const raw = (src, out, extra = []) => {
46
+ execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', src, ...extra,
47
+ '-f', 's16le', '-acodec', 'pcm_s16le', '-ar', String(SR), '-ac', '1', out]);
48
+ };
49
+ const aPath = join(dir, 'a.raw'), nPath = join(dir, 'n.raw');
50
+ try {
51
+ raw(VIDEO, aPath, ['-vn']);
52
+ raw(NARR, nPath);
53
+ const A = readFileSync(aPath), N = readFileSync(nPath);
54
+
55
+ // compare only where the narration has any energy; lead-in silence would
56
+ // otherwise dominate and flatter the result
57
+ const samples = Math.floor(Math.min(A.length, N.length) / 2);
58
+ let num = 0, den = 0;
59
+ const win = Math.round(SR * 0.5);
60
+ const active = [];
61
+ for (let s = 0; s < samples; s += win) {
62
+ let e = 0;
63
+ for (let i = s; i < Math.min(s + win, samples); i++) {
64
+ const v = N.readInt16LE(i * 2);
65
+ e += v * v;
66
+ }
67
+ active.push(e);
68
+ }
69
+ const eMax = Math.max(...active) || 1;
70
+ for (let s = 0; s < samples; s++) {
71
+ if (active[Math.floor(s / win)] < eMax * 0.002) continue; // skip near-silence
72
+ const av = A.readInt16LE(s * 2), nv = N.readInt16LE(s * 2);
73
+ num += (av - nv) * (av - nv);
74
+ den += nv * nv;
75
+ }
76
+ const residual = den > 0 ? Math.sqrt(num / den) : NaN;
77
+ const ok = residual <= MAX;
78
+ console.log(` residual (difference energy / signal energy): ${residual.toFixed(4)} (max ${MAX})`);
79
+ console.log(ok
80
+ ? ' ✓ finished audio matches the narration'
81
+ : ' ✗ finished audio does NOT match the narration — a part is playing the wrong slice');
82
+ rmSync(dir, { recursive: true, force: true });
83
+ process.exit(ok ? 0 : 2);
84
+ } catch (e) {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ console.error('[verify-audio] failed:', e.message.split('\n')[0]);
87
+ process.exit(1);
88
+ }
@@ -0,0 +1,124 @@
1
+ /* =============================================================================
2
+ workdir.mjs — one work dir per video, enforced rather than assumed.
3
+
4
+ Why this exists
5
+ ---------------
6
+ The per-video work dir (`out/<name>/<NN>/`) holds artefacts named the SAME for
7
+ every video: `narration.wav`, `timeline.json`, `timeline.js`, `lines.json`,
8
+ `manifest.json`, the rendered MP4. Nothing in those names carries the video's
9
+ identity, so pointing two videos at one directory does not fail — the second
10
+ run silently overwrites the first's timeline, and every later stage then reads
11
+ a timeline that describes a different video. Stage 4 is where that first
12
+ becomes possible, which is why this check runs there.
13
+
14
+ The fix is an identity marker, not a filename heuristic. A directory may hold
15
+ whatever it likes — plan files, a `scripts/` dir, a single-video plan whose
16
+ work dir genuinely IS the plan dir — and that is all fine. The only thing that
17
+ is refused is a directory already claimed by a DIFFERENT video.
18
+
19
+ Identity is the plan's `videoIndex`: it is the stable, monotonic key the whole
20
+ pipeline is indexed by (see `naming.mjs`). The title is recorded too, but a
21
+ title change is NOT a conflict — re-running `apply-enrichment` legitimately
22
+ retitles a video, so a same-index/different-title run updates the marker and
23
+ carries on.
24
+
25
+ Cross-stage behaviour belongs in a tool, not in either SKILL.md — `build-audio`,
26
+ `align` and `assemble` all import this so they cannot disagree.
27
+ ========================================================================== */
28
+
29
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+
32
+ const MARKER = '.video-identity';
33
+
34
+ function readMarker(dir) {
35
+ const p = join(dir, MARKER);
36
+ if (!existsSync(p)) return null;
37
+ try {
38
+ const j = JSON.parse(readFileSync(p, 'utf8'));
39
+ return j && typeof j === 'object' ? j : { corrupt: true };
40
+ } catch {
41
+ return { corrupt: true };
42
+ }
43
+ }
44
+
45
+ /* Atomic: a half-written marker would read as corrupt and block a legitimate run. */
46
+ function writeMarker(dir, identity) {
47
+ mkdirSync(dir, { recursive: true });
48
+ const p = join(dir, MARKER);
49
+ const tmp = p + '.' + process.pid + '.tmp';
50
+ writeFileSync(tmp, JSON.stringify(identity, null, 2) + '\n');
51
+ renameSync(tmp, p);
52
+ }
53
+
54
+ /**
55
+ * Claim (or verify) a work dir for one video.
56
+ *
57
+ * Exits non-zero when the dir belongs to a different `videoIndex`, naming both
58
+ * videos and the path it was asked to write. Otherwise returns the identity now
59
+ * recorded, writing or refreshing the marker as needed.
60
+ *
61
+ * @param {string} workDir the per-video work dir the caller was given
62
+ * @param {object} script the parsed script.json
63
+ * @param {string} tool short stage label for messages, e.g. 'build-audio'
64
+ */
65
+ export function claimWorkDir(workDir, script, tool = 'stage') {
66
+ const idxRaw = script && script.videoIndex != null ? Number(script.videoIndex) : null;
67
+ const idx = Number.isFinite(idxRaw) ? idxRaw : null;
68
+ const title = String((script && script.title) || '').trim();
69
+
70
+ const label = i => (i && i.title ? `video ${i.videoIndex} — "${i.title}"` : `video ${i.videoIndex}`);
71
+ const mine = idx != null ? `video ${idx}${title ? ` — "${title}"` : ''}` : `an unnamed video${title ? ` ("${title}")` : ''}`;
72
+
73
+ const prev = readMarker(workDir);
74
+
75
+ if (prev && prev.corrupt) {
76
+ console.error(`\n[workdir] ${workDir}/${MARKER} is unreadable — rewriting it for ${mine}.`);
77
+ console.error(`[workdir] If that dir was already in use by another video, its artefacts may be mixed.`);
78
+ } else if (prev && prev.videoIndex != null && idx != null && Number(prev.videoIndex) !== idx) {
79
+ console.error(`
80
+ [workdir] Refusing to write: this work dir already belongs to ${label(prev)}.
81
+
82
+ dir ${workDir}
83
+ ${tool.padEnd(8)} asked for ${mine}
84
+
85
+ Every video's work dir holds the same file names — narration.wav,
86
+ timeline.json, timeline.js, lines.json, manifest.json — so writing a
87
+ second video here silently overwrites the first's timeline and every
88
+ later stage then reads the wrong video's timings.
89
+
90
+ Give each video its own dir:
91
+
92
+ --out out/<name>/<videoIndex> e.g. --out out/<name>/${idx}
93
+
94
+ If ${label(prev)} is finished with this dir, move or delete it first.
95
+ `);
96
+ process.exit(3);
97
+ } else if (prev && prev.videoIndex == null && idx == null && prev.title && title && prev.title !== title) {
98
+ console.error(`\n[workdir] warning: ${workDir} holds "${prev.title}" but was given "${title}",`);
99
+ console.error(`[workdir] and neither script declares a videoIndex — assuming one video. Set videoIndex in the`);
100
+ console.error(`[workdir] script to get a hard guarantee.`);
101
+ }
102
+
103
+ const identity = {
104
+ videoIndex: idx,
105
+ title: title || null,
106
+ claimed_by: tool,
107
+ claimed_at: new Date().toISOString(),
108
+ ...(prev && prev.claimed_at && idx != null && Number(prev.videoIndex) === idx ? { first_claimed_at: prev.first_claimed_at || prev.claimed_at } : {}),
109
+ };
110
+
111
+ /* A same-index title change is legitimate (re-enrichment retitles videos). */
112
+ const titleChanged = !!(prev && !prev.corrupt && prev.title && title && prev.title !== title);
113
+ writeMarker(workDir, identity);
114
+ if (titleChanged) {
115
+ console.log(`[workdir] retitled in place: "${prev.title}" → "${title}" (same videoIndex ${idx})`);
116
+ }
117
+
118
+ return identity;
119
+ }
120
+
121
+ /** Read a work dir's identity without claiming it (for reporting/diagnostics). */
122
+ export function readWorkDirIdentity(workDir) {
123
+ return readMarker(workDir);
124
+ }