perso-dubbing 0.2.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.
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env node
2
+ // /dubbing entry orchestrator.
3
+ // key gate → input(s) → per-input split → global pool scheduler (all inputs×parts×languages in one queue) → per-input/per-language merge → notice.
4
+ // usage: node scripts/dubbing.mjs "<local|URL|folder>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder]
5
+ // node scripts/dubbing.mjs --resume "<statefile>"
6
+ import { writeFileSync, readFileSync, copyFileSync, mkdirSync, readdirSync, unlinkSync, renameSync, realpathSync } from 'node:fs';
7
+ import { rm } from 'node:fs/promises';
8
+ import { spawn } from 'node:child_process';
9
+ import { join, basename, dirname, extname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createInterface } from 'node:readline/promises';
12
+ import { resolveKey, onboardingHelp, preloadKeyEnv } from './resolve_key.mjs';
13
+ import { expandInputs, prepareInput } from '../lib/input.mjs';
14
+ import { dubbingSpaces, getPlanStatus } from '../lib/space.mjs';
15
+ import { getLanguages } from '../lib/languages.mjs';
16
+ import { resolveChunks, recutChunk } from '../lib/split.mjs';
17
+ import { runSchedule } from '../lib/scheduler.mjs';
18
+ import { download, getStatus } from '../lib/api_adapter.mjs';
19
+ import { mergeGroups } from '../lib/merge.mjs';
20
+ import { messages } from '../lib/messages.mjs';
21
+ import { cleanupTempDirs, makeTempDir } from '../lib/tmp.mjs';
22
+
23
+ const log = (m) => console.error(' ' + m); // background verbose log (stderr)
24
+ // Milestones exposed to the user (stdout). The agent relays these [progress] lines to chat per the SKILL rules.
25
+ const notify = (m) => console.log(`[progress] ${m}`);
26
+
27
+ // Convert API errors into user-friendly text (avoid exposing raw codes/messages)
28
+ function isAuthError(e) {
29
+ return e?.name === 'PersoApiError' && (e.httpStatus === 401 || ['A0009', 'A0010', 'A0011'].includes(e.code ?? ''));
30
+ }
31
+ function friendlyError(e) {
32
+ if (e?.name === 'MissingKeyError' || isAuthError(e)) {
33
+ return 'API key is missing or invalid (it may be expired or mistyped). Re-register it and try again.\n\n' + onboardingHelp();
34
+ }
35
+ if (e?.name === 'PersoApiError') return 'Something went wrong while processing. Please try again in a moment.';
36
+ return e?.message ?? 'Unknown error';
37
+ }
38
+
39
+ // Key gate with self-healing: when no key is registered, hand off to `resolve_key.mjs --watch` in a child
40
+ // process (creates the key file, opens it in the editor, encrypts on save, deletes the file) and continue
41
+ // once registered. Runs in a child because Windows DPAPI work (powershell) must not run in this main process.
42
+ // PERSO_NO_WATCH=1 restores the old fail-fast behavior (headless/CI).
43
+ async function ensureKey() {
44
+ if (resolveKey()) return;
45
+ if (process.env.PERSO_NO_WATCH) {
46
+ console.error(onboardingHelp());
47
+ throw new ExitCode(2);
48
+ }
49
+ notify('No API key registered — a key file will open; paste just your Perso API key and save it. (Get one: https://developers.perso.ai/api-keys)');
50
+ const watcher = fileURLToPath(new URL('./resolve_key.mjs', import.meta.url));
51
+ const code = await new Promise((res) => {
52
+ const child = spawn(process.execPath, [watcher, '--watch'], { stdio: 'inherit' });
53
+ child.on('close', res);
54
+ child.on('error', () => res(1));
55
+ });
56
+ preloadKeyEnv();
57
+ if (code !== 0 || !resolveKey()) {
58
+ console.error(onboardingHelp());
59
+ throw new ExitCode(2);
60
+ }
61
+ notify('API key registered — continuing.');
62
+ }
63
+
64
+ // Space gate: which workspace to dub in. The user only ever sees space NAME + PLAN (never the seq):
65
+ // --space accepts a space name (or a raw seq for scripts); PERSO_SPACE_SEQ pins it; a single space is used
66
+ // as-is. With several spaces the user chooses — interactively on a TTY; otherwise (agent/background) the
67
+ // name+plan options are printed as [space-select] lines and the run exits with code 3 → re-run with
68
+ // --space "<space name>".
69
+ async function ensureSpace(args) {
70
+ const wanted = args.space != null ? String(args.space).trim() : '';
71
+ if (/^\d+$/.test(wanted)) return Number(wanted); // raw seq — power users/scripts; not shown to end users
72
+ const pinned = Number(process.env.PERSO_SPACE_SEQ);
73
+ if (!wanted && pinned) return pinned;
74
+
75
+ const spaces = await dubbingSpaces();
76
+ // Options shown to the user carry "name | (plan) | remaining credits" — never the internal seq.
77
+ // Credits are fetched only when a list is actually displayed (one plan-status call per option).
78
+ const enrich = (list) => Promise.all(list.map(async (s) => ({ ...s, credits: (await getPlanStatus(s.seq))?.remainingQuota ?? null })));
79
+ const label = (s) => [s.name, s.tier ? `(${s.tier})` : '(-)', s.credits != null ? `${s.credits} credits left` : 'credits unknown'].join(' | ');
80
+ const brief = (s) => `${s.name}${s.tier ? ` (${s.tier})` : ''}`;
81
+
82
+ if (wanted) {
83
+ const hits = spaces.filter((s) => s.name.toLowerCase() === wanted.toLowerCase());
84
+ if (hits.length === 1) { console.log(`Space: ${brief(hits[0])}`); return hits[0].seq; }
85
+ if (hits.length > 1) {
86
+ console.log(`[space-select] Several spaces share the name "${wanted}" — rename one in Perso, or pin PERSO_SPACE_SEQ:`);
87
+ for (const s of await enrich(hits)) console.log(` PERSO_SPACE_SEQ=${s.seq} → ${label(s)}`);
88
+ throw new ExitCode(3);
89
+ }
90
+ console.log(`[space-select] No space named "${wanted}". Ask the user to pick one of these:`);
91
+ for (const s of await enrich(spaces)) console.log(` - ${label(s)}`);
92
+ throw new ExitCode(3);
93
+ }
94
+
95
+ if (spaces.length === 1) return spaces[0].seq;
96
+ const options = await enrich(spaces);
97
+ if (process.stdin.isTTY && process.stdout.isTTY) {
98
+ console.log('Several spaces are available. Which one should this dubbing run in?');
99
+ options.forEach((s, i) => console.log(` ${i + 1}) ${label(s)}`));
100
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
101
+ const answer = (await rl.question('Select (number or name): ')).trim();
102
+ rl.close();
103
+ const chosen = options[Number(answer) - 1] ?? options.find((s) => s.name.toLowerCase() === answer.toLowerCase());
104
+ if (!chosen) { console.error('Invalid selection — run again.'); throw new ExitCode(1); }
105
+ console.log(`Space: ${brief(chosen)}`);
106
+ return chosen.seq;
107
+ }
108
+ console.log('[space-select] Several spaces are available — show the user ONLY these options (name | plan | credits left), ask which one to dub in, then re-run the same command with --space "<space name>":');
109
+ for (const s of options) console.log(` - ${label(s)}`);
110
+ throw new ExitCode(3);
111
+ }
112
+
113
+ const USAGE = [
114
+ 'Usage: node scripts/dubbing.mjs "<file|folder|URL>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder] [--recursive]',
115
+ ' node scripts/dubbing.mjs --resume "<state-file>"',
116
+ ].join('\n');
117
+
118
+ class UsageError extends Error {
119
+ constructor(msg) { super(msg); this.name = 'UsageError'; }
120
+ }
121
+
122
+ // Signals "stop with this exit code" after the message was already printed. Thrown instead of calling
123
+ // process.exit() directly: a hard exit while fetch sockets are tearing down hits a Windows libuv assert
124
+ // (async.c) and corrupts the exit code — main() sets process.exitCode and lets the loop drain instead.
125
+ class ExitCode extends Error {
126
+ constructor(code) { super(`exit ${code}`); this.name = 'ExitCode'; this.code = code; }
127
+ }
128
+
129
+ function parseArgs(argv) {
130
+ const a = { source: 'auto', target: 'en', inputs: [] };
131
+ const VALUE_FLAGS = { '--resume': 'resume', '--source': 'source', '--target': 'target', '--space': 'space', '--out': 'out' };
132
+ for (let i = 0; i < argv.length; i++) {
133
+ const t = argv[i];
134
+ if (t === '--help' || t === '-h') a.help = true;
135
+ else if (t === '--recursive') a.recursive = true;
136
+ else if (t in VALUE_FLAGS) {
137
+ const v = argv[++i];
138
+ if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
139
+ a[VALUE_FLAGS[t]] = v;
140
+ } else if (t.startsWith('--')) {
141
+ throw new UsageError(`Unknown option: ${t}`); // typos must not be swallowed as input paths
142
+ } else a.inputs.push(t); // positional args (multiple): URL/path/folder may be mixed
143
+ }
144
+ return a;
145
+ }
146
+
147
+ // Language codes are validated up-front: a typo is a permanent error, so fail with the supported list
148
+ // instead of a mid-run "try again". Best-effort — if the list can't be fetched, let the server decide.
149
+ async function validateLanguages(targets, source) {
150
+ const langs = await getLanguages().catch(() => []);
151
+ const codes = langs.map((l) => (typeof l === 'string' ? l : l.code ?? l.languageCode)).filter(Boolean);
152
+ if (!codes.length) return { targets, source };
153
+ const canon = new Map(codes.map((c) => [String(c).toLowerCase(), c]));
154
+ const fixed = targets.map((t) => {
155
+ const hit = canon.get(t.toLowerCase());
156
+ if (!hit) {
157
+ console.error(`Unsupported target language code: "${t}"\nSupported: ${codes.join(', ')}`);
158
+ throw new ExitCode(1);
159
+ }
160
+ return hit;
161
+ });
162
+ let src = source;
163
+ if (source && source !== 'auto') {
164
+ const hit = canon.get(source.toLowerCase());
165
+ if (!hit) {
166
+ console.error(`Unsupported source language code: "${source}" (use "auto" to detect)\nSupported: ${codes.join(', ')}`);
167
+ throw new ExitCode(1);
168
+ }
169
+ src = hit;
170
+ }
171
+ return { targets: fixed, source: src };
172
+ }
173
+
174
+ const labelOf = (inp) => inp?.originalName ?? inp?.sourceUrl ?? 'input';
175
+ const refOf = (inp) => (inp.source === 'local'
176
+ ? { source: 'local', localPath: inp.localPath, originalName: inp.originalName }
177
+ : { source: inp.source, sourceUrl: inp.sourceUrl, originalName: inp.originalName ?? null });
178
+ // Notice text for skipping an unsupported format (append the reason if present).
179
+ const skipMsg = (name, e) => `Skipped (unsupported format): ${name}${e?.cause?.message ? ` (${e.cause.message})` : ''}`;
180
+
181
+ // Save directory (non-volatile): next to the local original; current folder for URL/external/unknown.
182
+ function inputSaveDir(inp) {
183
+ if (inp?.source === 'local' && inp.localPath) return dirname(inp.localPath);
184
+ return process.cwd();
185
+ }
186
+
187
+ // Resume state-file location — stores no video data (lightweight) and lives in a non-volatile location (survives temp cleanup).
188
+ // if --out is set, next to/inside it; otherwise next to the single local original; else current folder.
189
+ function resumePath({ out, inputs, multiInput }) {
190
+ if (out) return multiInput ? join(out, '.dubresume.json') : out + '.dubresume.json';
191
+ const only = inputs.length === 1 ? inputs[0] : null;
192
+ if (only?.source === 'local' && only.localPath) return only.localPath + '.dubresume.json';
193
+ return join(process.cwd(), 'dubbing-resume.json');
194
+ }
195
+
196
+ // --out single input: add a language suffix if multilingual (single language stays as-is).
197
+ // The extension class excludes path separators — a dot in a FOLDER name must not be taken as the extension.
198
+ function explicitOutPath(argOut, target, multiLang) {
199
+ return multiLang ? argOut.replace(/(\.[^.\\/]+)?$/, `.${target}$1`) : argOut;
200
+ }
201
+
202
+ // If the output filename is already taken, append a _2,_3… suffix before the extension to avoid collisions (also registering it into used).
203
+ function uniqueName(fname, used) {
204
+ if (!used.has(fname)) { used.add(fname); return fname; }
205
+ const dot = fname.lastIndexOf('.');
206
+ const stem = dot > 0 ? fname.slice(0, dot) : fname;
207
+ const ext = dot > 0 ? fname.slice(dot) : '';
208
+ let n = 2, cand;
209
+ do { cand = `${stem}_${n}${ext}`; n++; } while (used.has(cand));
210
+ used.add(cand);
211
+ return cand;
212
+ }
213
+ // Track the set of in-use names per directory (existing files + this run) to avoid collisions.
214
+ function reserve(dir, name, usedByDir) {
215
+ let s = usedByDir.get(dir);
216
+ if (!s) { let init = []; try { init = readdirSync(dir); } catch { /* new folder */ } s = new Set(init); usedByDir.set(dir, s); }
217
+ return uniqueName(name, s);
218
+ }
219
+
220
+ // Determine the final save path for one (input × language) bundle + create the directory (if needed).
221
+ // 1) single input + --out → that file (_language if multilingual, _2,_3… if multiple outputs). [user-specified takes precedence]
222
+ // 2) single result without split + Perso filename → keep the Perso name as-is (includes language/timestamp → no rename needed).
223
+ // 3) otherwise (split merge, etc.) → <originalName>.dubbed.<language>.<ext> (_2,_3… if multiple).
224
+ // Save folder: --out (folder if multi-input) > next to the input original > current folder.
225
+ function targetPaths(outputs, ctx) {
226
+ const { inp, target, isSplit, multiInput, multiLang, out, usedByDir } = ctx;
227
+ if (out && !multiInput) {
228
+ const file = explicitOutPath(out, target, multiLang);
229
+ mkdirSync(dirname(file), { recursive: true });
230
+ return outputs.length === 1 ? [file] : outputs.map((_, i) => file.replace(/(\.[^.\\/]+)?$/, `_${i + 1}$1`));
231
+ }
232
+ const dir = (out && multiInput) ? out : inputSaveDir(inp);
233
+ mkdirSync(dir, { recursive: true });
234
+ const names = [];
235
+ if (!isSplit && outputs.length === 1 && outputs[0].name) {
236
+ names.push(reserve(dir, basename(outputs[0].name), usedByDir)); // keep the Perso name as-is (includes timestamp)
237
+ } else {
238
+ const ext = extname(outputs[0]?.name || outputs[0]?.path || '') || '.mp4';
239
+ const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
240
+ outputs.forEach((_, i) => {
241
+ const base = `${stem}.dubbed.${target}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`;
242
+ names.push(reserve(dir, base, usedByDir));
243
+ });
244
+ }
245
+ return names.map((n) => join(dir, n));
246
+ }
247
+
248
+ // Persistable completion entry for one result — what resume needs to skip/re-download. null = nothing worth keeping.
249
+ function doneEntry(r) {
250
+ if (r.status === 'OK' || r.status === 'DLFAIL') return { status: 'OK', projectSeq: r.projectId }; // DLFAIL: generated → re-download on resume
251
+ if (r.status === 'PASSTHROUGH') return { status: 'PASSTHROUGH' };
252
+ return null;
253
+ }
254
+
255
+ // Lightweight manifest (v4) holding the chunk plan (boundaries) + only the completion status per (input|part|language).
256
+ function buildManifest(ctx, perInput, results, prevDone = {}) {
257
+ const done = { ...prevDone };
258
+ for (const r of results) {
259
+ const e = doneEntry(r);
260
+ if (e) done[`${r.inputId}|${r.index}|${r.target}`] = e;
261
+ }
262
+ return {
263
+ version: 4, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
264
+ inputs: perInput.map((p) => ({
265
+ inputId: p.inputId, ref: p.ref,
266
+ chunks: p.chunks.map((c) => ({
267
+ index: c.index, source: c.source, sourceUrl: c.sourceUrl ?? null,
268
+ startMs: c.startMs ?? null, endMs: c.endMs ?? null, title: c.title ?? null, kind: c.kind ?? null,
269
+ })),
270
+ })),
271
+ done,
272
+ };
273
+ }
274
+
275
+ // Incremental resume-state saving: the manifest is written as soon as a chunk plan is known and rewritten after every
276
+ // completed piece, so a run that dies mid-way (crash, Ctrl-C, shell timeout, sleep) can still resume instead of losing paid work.
277
+ function manifestSaver(ctx, perInput, prevDone = {}) {
278
+ const done = { ...prevDone };
279
+ const writeNow = () => {
280
+ try {
281
+ mkdirSync(dirname(ctx.file), { recursive: true });
282
+ const tmp = ctx.file + '.tmp';
283
+ writeFileSync(tmp, JSON.stringify(buildManifest(ctx, perInput, [], done)), 'utf8');
284
+ renameSync(tmp, ctx.file); // swap so a crash mid-write can't leave a corrupt manifest
285
+ } catch { /* best-effort — saving state must never break the run */ }
286
+ };
287
+ const onResult = (r) => {
288
+ const e = doneEntry(r);
289
+ if (e) { done[`${r.inputId}|${r.index}|${r.target}`] = e; writeNow(); }
290
+ };
291
+ return { writeNow, onResult };
292
+ }
293
+
294
+ // Sum of remaining (unprocessed) chunk durations → minutes (rounded up). null if only boundary-less chunks exist.
295
+ function remainingMinutes(chunks) {
296
+ const ms = (chunks || []).reduce(
297
+ (s, c) => s + (Number.isFinite(c?.startMs) && Number.isFinite(c?.endMs) ? c.endMs - c.startMs : 0),
298
+ 0,
299
+ );
300
+ return ms > 0 ? Math.ceil(ms / 60000) : null;
301
+ }
302
+
303
+ // Group the global pool results by input and language to merge and save them, and if incomplete (credit/download failure), preserve resume state as a manifest.
304
+ // ctx: { spaceSeq, source, targets, out, multiInput, sched, file, prevDone }
305
+ async function finishPool(allResults, perInput, ctx) {
306
+ const usedByDir = new Map();
307
+ const multiLang = ctx.targets.length > 1;
308
+ let okCount = 0, failCount = 0;
309
+ const lines = [];
310
+
311
+ for (const pin of perInput) {
312
+ const inRes = allResults.filter((r) => r.inputId === pin.inputId);
313
+ const isSplit = pin.chunks.length > 1;
314
+ for (const target of ctx.targets) {
315
+ const tRes = inRes.filter((r) => r.target === target);
316
+ if (!tRes.length) continue; // all canceled / no results
317
+ const tlab = multiLang ? `${labelOf(pin.inp)} (${target})` : labelOf(pin.inp);
318
+ const mergeable = tRes.filter((r) => r.status === 'OK' || r.status === 'PASSTHROUGH');
319
+ if (mergeable.length && mergeable.every((r) => r.status === 'PASSTHROUGH')) {
320
+ // every processed part was silent — merging would just hand the original back as a "dubbed" result
321
+ lines.push(`Could not dub: ${tlab} — no voice detected (nothing to dub)`);
322
+ failCount++;
323
+ continue;
324
+ }
325
+ if (mergeable.length > 1) notify(`Merging — ${labelOf(pin.inp)}${multiLang ? ` (${target})` : ''}`);
326
+ const { outputs, report } = await mergeGroups(tRes);
327
+ let saved = [];
328
+ if (outputs.length) {
329
+ const paths = targetPaths(outputs, { inp: pin.inp, target, isSplit, multiInput: ctx.multiInput, multiLang, out: ctx.out, usedByDir });
330
+ outputs.forEach((o, i) => copyFileSync(o.path, paths[i]));
331
+ await rm(dirname(outputs[0].path), { recursive: true, force: true }).catch(() => {}); // clean up merge temp folder
332
+ saved = paths;
333
+ }
334
+ if (saved.length) {
335
+ lines.push(`Done: ${tlab} → ${saved.map((p) => basename(p)).join(', ')}${report ? ' (some parts excluded)' : ''}`);
336
+ okCount++;
337
+ } else {
338
+ lines.push(`Could not dub: ${tlab} — ${report ?? 'no result'}`);
339
+ failCount++;
340
+ }
341
+ }
342
+ }
343
+ if (lines.length) console.log(lines.join('\n'));
344
+ if (perInput.length > 1 || multiLang) console.log(`\nSummary: ${okCount} done · ${failCount} failed`);
345
+
346
+ // If it stopped again (out of credit) or only downloads failed, save the manifest → resume.
347
+ const dlPending = allResults.some((r) => r.status === 'DLFAIL');
348
+ const stopped = !!ctx.sched?.stopped;
349
+ if (stopped || dlPending) {
350
+ if (ctx.multiInput && ctx.out) mkdirSync(ctx.out, { recursive: true });
351
+ writeFileSync(ctx.file, JSON.stringify(buildManifest(ctx, perInput, allResults, ctx.prevDone ?? {})), 'utf8');
352
+ if (stopped) {
353
+ const plan = await getPlanStatus(ctx.spaceSeq);
354
+ const min = remainingMinutes(ctx.sched.pendingLeft);
355
+ console.log('\n' + messages.quotaExceeded({
356
+ planTier: plan?.planTier,
357
+ remainingQuota: plan?.remainingQuota,
358
+ remainingNote: min != null ? `~${min} min` : null,
359
+ resumeHint: `node scripts/dubbing.mjs --resume "${ctx.file}"`,
360
+ }));
361
+ } else {
362
+ console.log(`\nSome parts are still finishing on the server or could not be downloaded — resume later to fetch them (no re-dub, no extra credits):\n node scripts/dubbing.mjs --resume "${ctx.file}"`);
363
+ }
364
+ } else {
365
+ try { unlinkSync(ctx.file); } catch { /* done → clean up resume state-file (ignore if absent) */ }
366
+ }
367
+ }
368
+
369
+ // New run: schedule all inputs as a single pool. Per-input split/upload happens once (secures mediaSeq) → reused per language.
370
+ async function runPool(args) {
371
+ await ensureKey();
372
+ const wantedTargets = String(args.target).split(',').map((t) => t.trim()).filter(Boolean); // --target en,ja,ko
373
+ if (!wantedTargets.length) throw new UsageError('No target language specified (--target en,ja,...)');
374
+ const { targets, source } = await validateLanguages(wantedTargets, args.source); // typo-fail before asking anything
375
+ const spaceSeq = await ensureSpace(args); // ask before any download/upload work (cheap to re-run with --space)
376
+ const inputs = await expandInputs(args.inputs, { recursive: args.recursive });
377
+ const multiInput = inputs.length > 1;
378
+ const file = resumePath({ out: args.out, inputs, multiInput });
379
+ const ctx = { spaceSeq, source, targets, out: args.out, multiInput, file, prevDone: {} };
380
+
381
+ // Per-input split/upload → tag every part with inputId into a single pool.
382
+ const pool = [];
383
+ const perInput = [];
384
+ const saver = manifestSaver(ctx, perInput);
385
+ for (let id = 0; id < inputs.length; id++) {
386
+ const inp = inputs[id];
387
+ const tag = multiInput ? `[${id + 1}/${inputs.length}] ${labelOf(inp)}` : labelOf(inp);
388
+ let chunks;
389
+ try {
390
+ ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify }));
391
+ } catch (e) {
392
+ if (isAuthError(e)) { console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
393
+ if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; } // unsupported → skip
394
+ console.log(`${tag} — split/upload failed: ${friendlyError(e)}`); continue;
395
+ }
396
+ if (chunks.length > 1) notify(`Split complete — ${labelOf(inp)} (${chunks.length} parts)`);
397
+ for (const c of chunks) pool.push({ ...c, inputId: id });
398
+ perInput.push({ inputId: id, inp, ref: refOf(inp), chunks });
399
+ saver.writeNow(); // the chunk plan (boundaries) survives a crash from this point on
400
+ }
401
+ if (!pool.length) { notify('No inputs to process.'); return; }
402
+
403
+ notify(`Translating${targets.length > 1 ? ` (${targets.join(', ')})` : ''}`);
404
+ // Fill all inputs×parts×languages into one queue for concurrent processing. Submit as many as there are empty slots and add more every 5 minutes.
405
+ const sched = await runSchedule(pool, spaceSeq, { source, targets }, { log, onResult: saver.onResult });
406
+
407
+ await finishPool([...sched.results.values()], perInput, { ...ctx, sched });
408
+ }
409
+
410
+ // Resume: completed parts (OK) are re-downloaded from the server via projectSeq; the rest (PASSTHROUGH/unprocessed) are re-cut from the original and processed → merged.
411
+ async function runResume(file) {
412
+ await ensureKey();
413
+ const m = JSON.parse(readFileSync(file, 'utf8'));
414
+ if (m.version !== 4) throw new Error('Unsupported state-file format — run again from the original.');
415
+ const targets = m.targets ?? [m.opts?.target ?? 'en'];
416
+ const multiInput = (m.inputs?.length ?? 0) > 1;
417
+ const ctx = { spaceSeq: m.spaceSeq, source: m.opts?.source ?? 'auto', targets, out: m.out, multiInput, file, prevDone: m.done ?? {} };
418
+ const outDir = await makeTempDir('dubbing-resume-');
419
+ const matCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part, shared across languages)
420
+
421
+ const downloaded = [];
422
+ const skip = new Set();
423
+ const pool = [];
424
+ const perInput = [];
425
+ const saver = manifestSaver(ctx, perInput, m.done ?? {});
426
+
427
+ for (const pin of m.inputs) {
428
+ const inputStr = pin.ref.source === 'local' ? pin.ref.localPath : pin.ref.sourceUrl;
429
+ let prepared;
430
+ try {
431
+ prepared = await prepareInput(inputStr); // re-check local / re-download URL
432
+ } catch (e) {
433
+ console.log(`Input not found, skipping: ${pin.ref.originalName ?? inputStr} (${e.message})`);
434
+ continue;
435
+ }
436
+ const inp = { ...prepared, originalName: prepared.originalName ?? pin.ref.originalName };
437
+ const localPath = prepared.localPath ?? prepared.path ?? null;
438
+ const materialize = async (c) => {
439
+ if (c.source === 'external') return null; // external cannot be re-cut → resubmit as-is
440
+ if (!localPath) throw new Error('Original not found — cannot resume.');
441
+ if (c.endMs == null) return localPath; // if whole (not split), the original
442
+ const mk = `${pin.inputId}|${c.index}`;
443
+ if (!matCache.has(mk)) matCache.set(mk, await recutChunk(localPath, c.startMs, c.endMs));
444
+ return matCache.get(mk);
445
+ };
446
+
447
+ // Completed parts ((inputId|index|target) OK/PASSTHROUGH) use re-download/original; the rest are resubmission targets (excluded from skip).
448
+ for (const c of pin.chunks) {
449
+ for (const target of targets) {
450
+ const k = `${pin.inputId}|${c.index}|${target}`;
451
+ const d = m.done?.[k];
452
+ if (d?.status === 'OK') {
453
+ const out = join(outDir, `dub_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
454
+ try {
455
+ const dl = await download(d.projectSeq, m.spaceSeq, { kind: c.kind, outPath: out });
456
+ downloaded.push({ inputId: pin.inputId, index: c.index, target, status: 'OK', path: out, projectId: d.projectSeq, name: dl.fileName });
457
+ log(`[input ${pin.inputId + 1}] part ${c.index + 1}(${target}) re-downloaded`);
458
+ skip.add(k);
459
+ } catch {
460
+ // Not downloadable (yet). Only a confirmed server-side failure gets re-dubbed; otherwise keep the
461
+ // projectSeq and let a later resume fetch it — never re-spend credits on a job that may still finish.
462
+ let failedOnServer = false;
463
+ try { failedOnServer = (await getStatus(d.projectSeq, m.spaceSeq)).state === 'failed'; } catch { /* unknown → keep waiting */ }
464
+ if (failedOnServer) {
465
+ log(`[input ${pin.inputId + 1}] part ${c.index + 1}(${target}) failed on the server — will re-dub`);
466
+ } else {
467
+ downloaded.push({ inputId: pin.inputId, index: c.index, target, status: 'DLFAIL', projectId: d.projectSeq, reason: 'download_failed' });
468
+ log(`[input ${pin.inputId + 1}] part ${c.index + 1}(${target}) not ready/download failed — resume again later (no re-dub)`);
469
+ skip.add(k);
470
+ }
471
+ }
472
+ } else if (d?.status === 'PASSTHROUGH') {
473
+ downloaded.push({ inputId: pin.inputId, index: c.index, target, status: 'PASSTHROUGH', path: await materialize(c) });
474
+ skip.add(k);
475
+ }
476
+ }
477
+ }
478
+
479
+ // Re-cut only parts that have at least one incomplete language and add them to the pool (languages are filtered out via skip).
480
+ for (const c of pin.chunks) {
481
+ if (!targets.some((t) => !skip.has(`${pin.inputId}|${c.index}|${t}`))) continue;
482
+ if (c.source === 'external') pool.push({ inputId: pin.inputId, index: c.index, source: 'external', sourceUrl: c.sourceUrl, kind: c.kind });
483
+ else {
484
+ const path = await materialize(c);
485
+ pool.push({ inputId: pin.inputId, index: c.index, source: 'local', path, startMs: c.startMs, endMs: c.endMs, originalName: basename(path), title: c.title, kind: c.kind });
486
+ }
487
+ }
488
+ perInput.push({ inputId: pin.inputId, inp, ref: pin.ref, chunks: pin.chunks });
489
+ }
490
+
491
+ if (pool.length) notify('Translating (resume)');
492
+ const sched = pool.length
493
+ ? await runSchedule(pool, m.spaceSeq, { source: m.opts?.source ?? 'auto', targets, done: skip }, { log, onResult: saver.onResult })
494
+ : { results: new Map(), stopped: false, pendingLeft: [] };
495
+
496
+ await finishPool([...downloaded, ...sched.results.values()], perInput, { ...ctx, sched });
497
+ }
498
+
499
+ // Pure helper exports for testing (when run directly, only main below executes).
500
+ export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes };
501
+
502
+ async function main() {
503
+ let exitCode = 0;
504
+ try {
505
+ preloadKeyEnv(); // pre-inject the key into env before async (at a clean point) → avoid a synchronous powershell call/crash in the main process
506
+ const args = parseArgs(process.argv.slice(2));
507
+ if (args.help) console.log(USAGE);
508
+ else if (args.resume) await runResume(args.resume);
509
+ else if (!args.inputs.length) {
510
+ console.error(USAGE);
511
+ exitCode = 1;
512
+ } else await runPool(args);
513
+ } catch (e) {
514
+ if (e?.name === 'ExitCode') exitCode = e.code; // message already printed at the throw site
515
+ else if (e?.name === 'UsageError') { console.error(`${e.message}\n${USAGE}`); exitCode = 1; }
516
+ else { console.error(friendlyError(e)); exitCode = 1; }
517
+ } finally {
518
+ await cleanupTempDirs(); // bulk-clean the cut/schedule/merge/download temp folders
519
+ }
520
+ // Natural exit (loop drain) — process.exit() while fetch sockets are closing hits a Windows libuv assert
521
+ // (async.c) that corrupts the exit code. The unref'd timer is a hard-exit fallback if a handle ever hangs.
522
+ process.exitCode = exitCode;
523
+ setTimeout(() => process.exit(exitCode), 5000).unref();
524
+ }
525
+
526
+ // main only when run directly (CLI). When imported (tests), expose helpers only and do not execute.
527
+ // realpath both sides — Node resolves symlinks for the main module, so a skill installed via symlink/junction
528
+ // would otherwise silently never run main (argv[1] keeps the link path while import.meta.url is the real one).
529
+ const isMain = (() => {
530
+ if (!process.argv[1]) return false;
531
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
532
+ catch { return false; }
533
+ })();
534
+ if (isMain) await main();
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // Print supported language codes.
3
+ import { getLanguages } from '../lib/languages.mjs';
4
+
5
+ const langs = await getLanguages();
6
+ const codes = langs
7
+ .map((l) => (typeof l === 'string' ? l : l.code ?? l.languageCode))
8
+ .filter(Boolean);
9
+
10
+ console.log(`languages: ${codes.length}`);
11
+ console.log(codes.join(', '));
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // (standalone) Takes a results JSON array ([{index,status,path,reason}]) and merges groups.
3
+ import { mergeGroups } from '../lib/merge.mjs';
4
+
5
+ try {
6
+ const results = JSON.parse(process.argv[2] ?? '[]');
7
+ const { outputs, report } = await mergeGroups(results);
8
+ console.log(JSON.stringify({ outputs, report }));
9
+ } catch (e) {
10
+ console.error(e.message);
11
+ process.exit(1);
12
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ // Validate and normalize the input (local path or URL). Prints the result JSON to stdout.
3
+ // usage: node scripts/prepare_input.mjs "<local path|URL>"
4
+ import { prepareInput } from '../lib/input.mjs';
5
+
6
+ try {
7
+ const result = await prepareInput(process.argv[2]);
8
+ console.log(JSON.stringify(result));
9
+ } catch (e) {
10
+ console.error(e.message);
11
+ process.exit(1);
12
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Upload-first split decision. Takes prepare_input JSON (or a local path) as an argument.
3
+ // node scripts/probe_split.mjs '<json>' | "<local path>" (space: PERSO_SPACE_SEQ, else the account's only space)
4
+ import { resolveChunks } from '../lib/split.mjs';
5
+ import { resolveSpace } from '../lib/space.mjs';
6
+
7
+ function parseArg(arg) {
8
+ if (!arg) throw new Error('No input — pass prepare_input JSON or a local path.');
9
+ try {
10
+ const j = JSON.parse(arg);
11
+ if (j && typeof j === 'object') return j;
12
+ } catch {
13
+ /* treat as a path */
14
+ }
15
+ return { source: 'local', localPath: arg, originalName: arg.split(/[\\/]/).pop() };
16
+ }
17
+
18
+ try {
19
+ const prepared = parseArg(process.argv[2]);
20
+ const spaceSeq = Number(process.env.PERSO_SPACE_SEQ) || (await resolveSpace());
21
+ const result = await resolveChunks(prepared, spaceSeq);
22
+ console.log(JSON.stringify(result));
23
+ } catch (e) {
24
+ console.error(e.message);
25
+ process.exit(1);
26
+ }