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,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
|
+
}
|