perso-dubbing 0.2.0 → 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.
@@ -3,7 +3,7 @@
3
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
4
  // usage: node scripts/dubbing.mjs "<local|URL|folder>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder]
5
5
  // node scripts/dubbing.mjs --resume "<statefile>"
6
- import { writeFileSync, readFileSync, copyFileSync, mkdirSync, readdirSync, unlinkSync, renameSync, realpathSync } from 'node:fs';
6
+ import { writeFileSync, readFileSync, copyFileSync, mkdirSync, readdirSync, unlinkSync, renameSync, realpathSync, existsSync } from 'node:fs';
7
7
  import { rm } from 'node:fs/promises';
8
8
  import { spawn } from 'node:child_process';
9
9
  import { join, basename, dirname, extname } from 'node:path';
@@ -13,12 +13,15 @@ import { resolveKey, onboardingHelp, preloadKeyEnv } from './resolve_key.mjs';
13
13
  import { expandInputs, prepareInput } from '../lib/input.mjs';
14
14
  import { dubbingSpaces, getPlanStatus } from '../lib/space.mjs';
15
15
  import { getLanguages } from '../lib/languages.mjs';
16
- import { resolveChunks, recutChunk } from '../lib/split.mjs';
16
+ import { resolveChunks, recutChunk, SplitConfirmNeeded } from '../lib/split.mjs';
17
17
  import { runSchedule } from '../lib/scheduler.mjs';
18
- import { download, getStatus } from '../lib/api_adapter.mjs';
18
+ import { download, getStatus, upload, requestAudioSeparation, downloadSeparation } from '../lib/api_adapter.mjs';
19
19
  import { mergeGroups } from '../lib/merge.mjs';
20
- import { messages } from '../lib/messages.mjs';
20
+ import { messages, withUtm, SUBSCRIPTION_URL } from '../lib/messages.mjs';
21
+ import { checkForUpdate } from '../lib/update_check.mjs';
21
22
  import { cleanupTempDirs, makeTempDir } from '../lib/tmp.mjs';
23
+ import { probe } from '../lib/ffmpeg.mjs';
24
+ import { AUDIO_EXT, CREDIT_RATE_DUB, CREDIT_RATE_LIPSYNC, UHD_CREDIT_MULT, UHD_BILLED_TIERS, POLL_INTERVAL_MS, MAX_IDLE_MS } from '../lib/config.mjs';
22
25
 
23
26
  const log = (m) => console.error(' ' + m); // background verbose log (stderr)
24
27
  // Milestones exposed to the user (stdout). The agent relays these [progress] lines to chat per the SKILL rules.
@@ -111,8 +114,15 @@ async function ensureSpace(args) {
111
114
  }
112
115
 
113
116
  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]',
117
+ 'Usage: node scripts/dubbing.mjs "<file|folder|URL>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder] [--recursive] [--lipsync] [--force]',
118
+ ' node scripts/dubbing.mjs --lipsync-only "<project-ref JSON | projectSeq[,projectSeq...]>" [--space "space name"] [--out path|folder]',
119
+ ' node scripts/dubbing.mjs --separate "<file|folder|URL>" ["<another input>" ...] [--space "space name"] [--out folder]',
115
120
  ' node scripts/dubbing.mjs --resume "<state-file>"',
121
+ '',
122
+ ' --lipsync dub, then generate the lip-synced video (extra credits; takes much longer than dubbing)',
123
+ ' --lipsync-only lip-sync an already-dubbed project (uses the [project-ref] line printed by a finished run; no re-dub charge)',
124
+ ' --separate split the source into voice / background / sub-background audio tracks (no dubbing; ~0.5 credit per second)',
125
+ ' --force skip the upfront credit-estimate gate of --lipsync',
116
126
  ].join('\n');
117
127
 
118
128
  class UsageError extends Error {
@@ -128,11 +138,15 @@ class ExitCode extends Error {
128
138
 
129
139
  function parseArgs(argv) {
130
140
  const a = { source: 'auto', target: 'en', inputs: [] };
131
- const VALUE_FLAGS = { '--resume': 'resume', '--source': 'source', '--target': 'target', '--space': 'space', '--out': 'out' };
141
+ const VALUE_FLAGS = { '--resume': 'resume', '--source': 'source', '--target': 'target', '--space': 'space', '--out': 'out', '--lipsync-only': 'lipsyncOnly' };
132
142
  for (let i = 0; i < argv.length; i++) {
133
143
  const t = argv[i];
134
144
  if (t === '--help' || t === '-h') a.help = true;
135
145
  else if (t === '--recursive') a.recursive = true;
146
+ else if (t === '--separate') a.separate = true;
147
+ else if (t === '--lipsync') a.lipsync = true;
148
+ else if (t === '--force') a.force = true;
149
+ else if (t === '--allow-split') a.allowSplit = true; // user confirmed auto split→dub→merge (set on the re-run after [split-confirm])
136
150
  else if (t in VALUE_FLAGS) {
137
151
  const v = argv[++i];
138
152
  if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
@@ -178,6 +192,34 @@ const refOf = (inp) => (inp.source === 'local'
178
192
  // Notice text for skipping an unsupported format (append the reason if present).
179
193
  const skipMsg = (name, e) => `Skipped (unsupported format): ${name}${e?.cause?.message ? ` (${e.cause.message})` : ''}`;
180
194
 
195
+ // [split-confirm] prompt: which limit was exceeded + the quality caveat + how to proceed. Emitted (exit 3) before
196
+ // the first split when --allow-split isn't set; the agent relays it and re-runs with --allow-split on the user's OK.
197
+ function splitConfirmMessage(d, tag, action = 'dub') {
198
+ const min = (ms) => Math.max(1, Math.round(ms / 60000));
199
+ const gb = (b) => (b / 1024 ** 3).toFixed(1);
200
+ const label = tag ? `${tag}: ` : '';
201
+ const noun = action === 'separate' ? 'This file' : 'This video';
202
+ let head;
203
+ if (d.reason === 'length') {
204
+ const lim = d.limitMs ? `${min(d.limitMs)} min` : 'the plan limit';
205
+ head = d.actualMs
206
+ ? `${noun} exceeds the length limit (${lim}; it is ${min(d.actualMs)} min).`
207
+ : `${noun} exceeds the length limit (${lim}).`;
208
+ } else {
209
+ head = d.actualBytes
210
+ ? `${noun} exceeds the 2 GB upload limit (it is ${gb(d.actualBytes)} GB).`
211
+ : `${noun} exceeds the 2 GB upload limit.`;
212
+ }
213
+ const mid = action === 'separate'
214
+ ? 'Separating it needs automatic split → separate → merge, which may come out less polished than splitting it up yourself.'
215
+ : 'Dubbing it needs automatic split → dub → merge, which may come out less polished than splitting it up yourself.';
216
+ return [
217
+ `[split-confirm] ${label}${head}`,
218
+ `[split-confirm] ${mid}`,
219
+ '[split-confirm] Proceed automatically? To confirm, re-run the same command with --allow-split.',
220
+ ].join('\n');
221
+ }
222
+
181
223
  // Save directory (non-volatile): next to the local original; current folder for URL/external/unknown.
182
224
  function inputSaveDir(inp) {
183
225
  if (inp?.source === 'local' && inp.localPath) return dirname(inp.localPath);
@@ -193,6 +235,18 @@ function resumePath({ out, inputs, multiInput }) {
193
235
  return join(process.cwd(), 'dubbing-resume.json');
194
236
  }
195
237
 
238
+ // A state file at the target path means an earlier run for this input never finished (it is deleted on
239
+ // success). Starting fresh would overwrite it and re-bill work the server already completed, so stop before
240
+ // any network/billing and hand the choice to the user (exit 3 = "ask the user", like [space-select]).
241
+ function guardExistingState(file) {
242
+ if (!existsSync(file)) return;
243
+ console.log(`[resume-check] An earlier run for this input did not finish — its state file still exists: ${file}`);
244
+ console.log('[resume-check] To finish it without paying again for the completed parts, run:');
245
+ console.log(`[resume-check] node scripts/dubbing.mjs --resume "${file}"`);
246
+ console.log('[resume-check] To discard it and start over instead (completed parts will be billed again), delete that state file and re-run this command.');
247
+ throw new ExitCode(3);
248
+ }
249
+
196
250
  // --out single input: add a language suffix if multilingual (single language stays as-is).
197
251
  // The extension class excludes path separators — a dot in a FOLDER name must not be taken as the extension.
198
252
  function explicitOutPath(argOut, target, multiLang) {
@@ -220,10 +274,10 @@ function reserve(dir, name, usedByDir) {
220
274
  // Determine the final save path for one (input × language) bundle + create the directory (if needed).
221
275
  // 1) single input + --out → that file (_language if multilingual, _2,_3… if multiple outputs). [user-specified takes precedence]
222
276
  // 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).
277
+ // 3) otherwise (split merge, etc.) → <originalName>.<suffix>.<language>.<ext> (_2,_3… if multiple; suffix: dubbed|lipsync).
224
278
  // Save folder: --out (folder if multi-input) > next to the input original > current folder.
225
279
  function targetPaths(outputs, ctx) {
226
- const { inp, target, isSplit, multiInput, multiLang, out, usedByDir } = ctx;
280
+ const { inp, target, isSplit, multiInput, multiLang, out, usedByDir, suffix = 'dubbed' } = ctx;
227
281
  if (out && !multiInput) {
228
282
  const file = explicitOutPath(out, target, multiLang);
229
283
  mkdirSync(dirname(file), { recursive: true });
@@ -238,7 +292,7 @@ function targetPaths(outputs, ctx) {
238
292
  const ext = extname(outputs[0]?.name || outputs[0]?.path || '') || '.mp4';
239
293
  const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
240
294
  outputs.forEach((_, i) => {
241
- const base = `${stem}.dubbed.${target}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`;
295
+ const base = `${stem}.${suffix}.${target}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`;
242
296
  names.push(reserve(dir, base, usedByDir));
243
297
  });
244
298
  }
@@ -246,13 +300,22 @@ function targetPaths(outputs, ctx) {
246
300
  }
247
301
 
248
302
  // Persistable completion entry for one result — what resume needs to skip/re-download. null = nothing worth keeping.
303
+ // OK{projectSeq} → final project (add lipsync:1 [+dubSeq] when the deliverable is the lip-sync video)
304
+ // OK{…, lipsyncFailed:1} → lip-sync failed permanently; the dubbed video is the deliverable (never retried)
305
+ // LSWAIT{projectSeq} → dubbing project exists, lip-sync still owed (resume runs only the lip-sync)
249
306
  function doneEntry(r) {
250
- if (r.status === 'OK' || r.status === 'DLFAIL') return { status: 'OK', projectSeq: r.projectId }; // DLFAIL: generated → re-download on resume
307
+ if (r.status === 'OK' || r.status === 'DLFAIL') { // DLFAIL: generated → re-download on resume
308
+ if (r.lipsyncPending) return { status: 'LSWAIT', projectSeq: r.projectId };
309
+ const e = { status: 'OK', projectSeq: r.projectId };
310
+ if (r.lipsync) { e.lipsync = 1; if (r.dubProjectId != null) e.dubSeq = r.dubProjectId; }
311
+ if (r.lipsyncFailed) e.lipsyncFailed = 1;
312
+ return e;
313
+ }
251
314
  if (r.status === 'PASSTHROUGH') return { status: 'PASSTHROUGH' };
252
315
  return null;
253
316
  }
254
317
 
255
- // Lightweight manifest (v4) holding the chunk plan (boundaries) + only the completion status per (input|part|language).
318
+ // Lightweight manifest (v5) holding the chunk plan (boundaries) + only the completion status per (input|part|language).
256
319
  function buildManifest(ctx, perInput, results, prevDone = {}) {
257
320
  const done = { ...prevDone };
258
321
  for (const r of results) {
@@ -260,12 +323,14 @@ function buildManifest(ctx, perInput, results, prevDone = {}) {
260
323
  if (e) done[`${r.inputId}|${r.index}|${r.target}`] = e;
261
324
  }
262
325
  return {
263
- version: 4, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
326
+ version: 5, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
327
+ lipsync: !!ctx.lipsync,
264
328
  inputs: perInput.map((p) => ({
265
329
  inputId: p.inputId, ref: p.ref,
266
330
  chunks: p.chunks.map((c) => ({
267
331
  index: c.index, source: c.source, sourceUrl: c.sourceUrl ?? null,
268
332
  startMs: c.startMs ?? null, endMs: c.endMs ?? null, title: c.title ?? null, kind: c.kind ?? null,
333
+ ...(c.parentSeq != null ? { parentSeq: c.parentSeq } : {}), // lipsync-only: the dubbed project behind this part
269
334
  })),
270
335
  })),
271
336
  done,
@@ -285,10 +350,21 @@ function manifestSaver(ctx, perInput, prevDone = {}) {
285
350
  } catch { /* best-effort — saving state must never break the run */ }
286
351
  };
287
352
  const onResult = (r) => {
353
+ const k = `${r.inputId}|${r.index}|${r.target}`;
288
354
  const e = doneEntry(r);
289
- if (e) { done[`${r.inputId}|${r.index}|${r.target}`] = e; writeNow(); }
355
+ if (e) { done[k] = e; writeNow(); }
356
+ else if (done[k]) { delete done[k]; writeNow(); } // hard failure after a submit checkpoint → drop the stale entry
357
+ };
358
+ // Submission checkpoint: the paid projectSeq is persisted the moment it exists, so even a hard kill
359
+ // before any result cannot cause a re-submission (= double billing) on resume.
360
+ const onSubmit = (s) => {
361
+ const k = `${s.inputId}|${s.index}|${s.target}`;
362
+ if (s.stage === 'lipsync') done[k] = { status: 'LSRUN', projectSeq: s.projectId, dubSeq: s.parentSeq };
363
+ else if (s.lipsync) done[k] = { status: 'LSWAIT', projectSeq: s.projectId }; // chain: this dub still owes a lip-sync
364
+ else done[k] = { status: 'RUN', projectSeq: s.projectId };
365
+ writeNow();
290
366
  };
291
- return { writeNow, onResult };
367
+ return { writeNow, onResult, onSubmit };
292
368
  }
293
369
 
294
370
  // Sum of remaining (unprocessed) chunk durations → minutes (rounded up). null if only boundary-less chunks exist.
@@ -300,6 +376,28 @@ function remainingMinutes(chunks) {
300
376
  return ms > 0 ? Math.ceil(ms / 60000) : null;
301
377
  }
302
378
 
379
+ // Machine-readable project reference, one line per delivered (input × language). Lets the agent lip-sync
380
+ // this exact dubbing later in the session (--lipsync-only) without re-dubbing. Not meant for end users.
381
+ function emitProjectRef(pin, tRes, target, ctx, { lipsync }) {
382
+ const parts = [];
383
+ for (const c of pin.chunks) {
384
+ const r = tRes.find((x) => x.index === c.index);
385
+ if (!r) return;
386
+ if (r.status === 'PASSTHROUGH') {
387
+ parts.push({ pt: [c.startMs ?? 0, c.endMs ?? 0] });
388
+ } else if (r.status === 'OK' || r.status === 'DLFAIL') {
389
+ const dubSeq = r.lipsync ? r.dubProjectId : r.projectId; // always reference the dubbing project (lip-sync reuse needs it)
390
+ if (dubSeq == null) return;
391
+ const part = { seq: dubSeq };
392
+ if (Number.isFinite(c.startMs) && Number.isFinite(c.endMs)) part.ms = [c.startMs, c.endMs];
393
+ parts.push(part);
394
+ } else return;
395
+ }
396
+ if (!parts.some((p) => p.seq != null)) return;
397
+ const input = pin.ref?.localPath ?? pin.ref?.sourceUrl ?? null;
398
+ console.log(`[project-ref] ${JSON.stringify({ v: 1, space: ctx.spaceSeq, input, lang: target, parts, lipsync: !!lipsync })}`);
399
+ }
400
+
303
401
  // 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
402
  // ctx: { spaceSeq, source, targets, out, multiInput, sched, file, prevDone }
305
403
  async function finishPool(allResults, perInput, ctx) {
@@ -322,18 +420,27 @@ async function finishPool(allResults, perInput, ctx) {
322
420
  failCount++;
323
421
  continue;
324
422
  }
423
+ const hasLs = mergeable.some((r) => r.lipsync);
424
+ const lsFailed = mergeable.some((r) => r.lipsyncFailed);
425
+ const lsPending = mergeable.some((r) => r.lipsyncPending);
325
426
  if (mergeable.length > 1) notify(`Merging — ${labelOf(pin.inp)}${multiLang ? ` (${target})` : ''}`);
326
427
  const { outputs, report } = await mergeGroups(tRes);
327
428
  let saved = [];
328
429
  if (outputs.length) {
329
- const paths = targetPaths(outputs, { inp: pin.inp, target, isSplit, multiInput: ctx.multiInput, multiLang, out: ctx.out, usedByDir });
430
+ const suffix = hasLs ? 'lipsync' : 'dubbed';
431
+ const paths = targetPaths(outputs, { inp: pin.inp, target, isSplit, multiInput: ctx.multiInput, multiLang, out: ctx.out, usedByDir, suffix });
330
432
  outputs.forEach((o, i) => copyFileSync(o.path, paths[i]));
331
433
  await rm(dirname(outputs[0].path), { recursive: true, force: true }).catch(() => {}); // clean up merge temp folder
332
434
  saved = paths;
333
435
  }
334
436
  if (saved.length) {
335
- lines.push(`Done: ${tlab} ${saved.map((p) => basename(p)).join(', ')}${report ? ' (some parts excluded)' : ''}`);
437
+ const notes = [];
438
+ if (report) notes.push('some parts excluded');
439
+ if (lsFailed) notes.push(hasLs ? 'lip-sync failed on some parts — dubbed video used for those' : 'lip-sync failed — dubbed video delivered instead');
440
+ if (lsPending) notes.push('lip-sync still pending — resume to finish it');
441
+ lines.push(`Done${hasLs ? ' (lip-sync)' : ''}: ${tlab} → ${saved.map((p) => basename(p)).join(', ')}${notes.length ? ` (${notes.join('; ')})` : ''}`);
336
442
  okCount++;
443
+ emitProjectRef(pin, tRes, target, ctx, { lipsync: hasLs && !lsFailed && !lsPending });
337
444
  } else {
338
445
  lines.push(`Could not dub: ${tlab} — ${report ?? 'no result'}`);
339
446
  failCount++;
@@ -352,11 +459,13 @@ async function finishPool(allResults, perInput, ctx) {
352
459
  if (stopped) {
353
460
  const plan = await getPlanStatus(ctx.spaceSeq);
354
461
  const min = remainingMinutes(ctx.sched.pendingLeft);
462
+ const lsOwed = allResults.some((r) => r.lipsyncPending);
355
463
  console.log('\n' + messages.quotaExceeded({
356
464
  planTier: plan?.planTier,
357
465
  remainingQuota: plan?.remainingQuota,
358
466
  remainingNote: min != null ? `~${min} min` : null,
359
467
  resumeHint: `node scripts/dubbing.mjs --resume "${ctx.file}"`,
468
+ note: lsOwed ? ' Dubbing for some items is already done — resume will run only the remaining lip-sync (no re-dub charge).' : null,
360
469
  }));
361
470
  } else {
362
471
  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}"`);
@@ -371,12 +480,22 @@ async function runPool(args) {
371
480
  await ensureKey();
372
481
  const wantedTargets = String(args.target).split(',').map((t) => t.trim()).filter(Boolean); // --target en,ja,ko
373
482
  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 });
483
+ let inputs = await expandInputs(args.inputs, { recursive: args.recursive });
484
+ if (args.lipsync) {
485
+ // Lip-sync applies to video only — drop audio inputs up-front (before any upload).
486
+ const isAudioInput = (i) => AUDIO_EXT.test(i.originalName ?? i.localPath ?? '');
487
+ const audio = inputs.filter(isAudioInput);
488
+ if (audio.length && audio.length === inputs.length) { console.error('Lip-sync requires video input.'); throw new ExitCode(1); }
489
+ for (const a of audio) notify(`Skipped (lip-sync requires video): ${labelOf(a)}`);
490
+ inputs = inputs.filter((i) => !isAudioInput(i));
491
+ notify('Lip-sync requested — dubbing first, then lip-sync generation (this takes considerably longer than dubbing alone).');
492
+ }
377
493
  const multiInput = inputs.length > 1;
378
494
  const file = resumePath({ out: args.out, inputs, multiInput });
379
- const ctx = { spaceSeq, source, targets, out: args.out, multiInput, file, prevDone: {} };
495
+ guardExistingState(file); // before validate/space/upload never silently restart (and re-bill) an interrupted run
496
+ const { targets, source } = await validateLanguages(wantedTargets, args.source); // typo-fail before asking anything
497
+ const spaceSeq = await ensureSpace(args); // ask before any download/upload work (cheap to re-run with --space)
498
+ const ctx = { spaceSeq, source, targets, out: args.out, multiInput, file, prevDone: {}, lipsync: !!args.lipsync };
380
499
 
381
500
  // Per-input split/upload → tag every part with inputId into a single pool.
382
501
  const pool = [];
@@ -387,8 +506,14 @@ async function runPool(args) {
387
506
  const tag = multiInput ? `[${id + 1}/${inputs.length}] ${labelOf(inp)}` : labelOf(inp);
388
507
  let chunks;
389
508
  try {
390
- ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify }));
509
+ ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
391
510
  } catch (e) {
511
+ if (e?.name === 'SplitConfirmNeeded') {
512
+ console.log(splitConfirmMessage(e.details, tag));
513
+ // Nothing is billed yet at the split stage, so discard any partial state so the --allow-split re-run isn't blocked by the resume guard.
514
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
515
+ throw new ExitCode(3);
516
+ }
392
517
  if (isAuthError(e)) { console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
393
518
  if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; } // unsupported → skip
394
519
  console.log(`${tag} — split/upload failed: ${friendlyError(e)}`); continue;
@@ -400,21 +525,63 @@ async function runPool(args) {
400
525
  }
401
526
  if (!pool.length) { notify('No inputs to process.'); return; }
402
527
 
528
+ if (args.lipsync && !args.force) await creditPreflight(perInput, spaceSeq, targets.length);
529
+
403
530
  notify(`Translating${targets.length > 1 ? ` (${targets.join(', ')})` : ''}`);
404
531
  // 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 });
532
+ const sched = await runSchedule(pool, spaceSeq, { source, targets, lipsync: !!args.lipsync }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit });
406
533
 
407
534
  await finishPool([...sched.results.values()], perInput, { ...ctx, sched });
408
535
  }
409
536
 
537
+ // Upfront credit-estimate gate for --lipsync: the dub+lip-sync chain is long, and running out of credits
538
+ // half-way strands the user mid-chain — warn before submitting anything when the estimate clearly exceeds
539
+ // the remaining credits. Durations aren't always known locally (no probe available, external URL) — then
540
+ // the gate is skipped and the server's own billing check decides.
541
+ async function creditPreflight(perInput, spaceSeq, targetCount) {
542
+ const plan = await getPlanStatus(spaceSeq);
543
+ const remaining = plan?.remainingQuota;
544
+ if (remaining == null || typeof remaining !== 'number') return;
545
+ // 4K+ surcharge applies to specific plans only (allowlist — an unknown tier gets no surcharge; the server bills authoritatively).
546
+ const uhdBilled = UHD_BILLED_TIERS.includes(String(plan.planTier ?? '').toLowerCase());
547
+ let needed = 0;
548
+ let anyUhd = false;
549
+ for (const pin of perInput) {
550
+ let inputMs = 0;
551
+ for (const c of pin.chunks) {
552
+ let ms = Number.isFinite(c.startMs) && Number.isFinite(c.endMs) ? c.endMs - c.startMs : null;
553
+ if (ms == null && c.source === 'local' && (c.path || pin.inp?.localPath)) {
554
+ ms = (await probe(c.path ?? pin.inp.localPath).catch(() => ({}))).durationMs ?? null;
555
+ }
556
+ if (ms == null || ms <= 0) return; // unknown duration → no estimate possible
557
+ inputMs += ms;
558
+ }
559
+ let mult = 1;
560
+ if (uhdBilled) {
561
+ const src = pin.inp?.localPath ?? pin.chunks.find((c) => c.path)?.path;
562
+ const { width = 0, height = 0 } = src ? await probe(src).catch(() => ({})) : {};
563
+ if (Math.max(width, height) >= 3840) { mult = UHD_CREDIT_MULT; anyUhd = true; } // resolution unknown → assume non-4K (estimate only; the server bills authoritatively)
564
+ }
565
+ needed += Math.ceil(inputMs / 1000) * (CREDIT_RATE_DUB + CREDIT_RATE_LIPSYNC) * targetCount * mult;
566
+ }
567
+ if (!needed || remaining >= needed) return;
568
+ console.log(`[credit-check] Estimated credits for dubbing + lip-sync: ~${needed}${anyUhd ? ` (includes the ×${UHD_CREDIT_MULT} 4K surcharge)` : ''}. Credits left: ${remaining}.`);
569
+ console.log('[credit-check] The run would stop part-way. Ask the user to top up first, or approve continuing anyway — then re-run the same command with --force (whatever completes is kept; the rest resumes later):');
570
+ console.log(` → ${withUtm(SUBSCRIPTION_URL)}`);
571
+ throw new ExitCode(3);
572
+ }
573
+
410
574
  // 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.
575
+ // v5 adds submit checkpoints (RUN/LSRUN) and the lip-sync chain (LSWAIT) — nothing already paid for is ever re-submitted.
411
576
  async function runResume(file) {
412
577
  await ensureKey();
413
578
  const m = JSON.parse(readFileSync(file, 'utf8'));
414
- if (m.version !== 4) throw new Error('Unsupported state-file format run again from the original.');
579
+ if (m.kind === 'separation') return runResumeSeparation(m, file); // separation has its own (scheduler-less) resume
580
+ if (m.version !== 4 && m.version !== 5) throw new Error('Unsupported state-file format — run again from the original.');
415
581
  const targets = m.targets ?? [m.opts?.target ?? 'en'];
416
582
  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 ?? {} };
583
+ const lipsync = !!m.lipsync;
584
+ const ctx = { spaceSeq: m.spaceSeq, source: m.opts?.source ?? 'auto', targets, out: m.out, multiInput, file, prevDone: m.done ?? {}, lipsync };
418
585
  const outDir = await makeTempDir('dubbing-resume-');
419
586
  const matCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part, shared across languages)
420
587
 
@@ -426,35 +593,45 @@ async function runResume(file) {
426
593
 
427
594
  for (const pin of m.inputs) {
428
595
  const inputStr = pin.ref.source === 'local' ? pin.ref.localPath : pin.ref.sourceUrl;
429
- let prepared;
596
+ let prepared = null;
430
597
  try {
431
598
  prepared = await prepareInput(inputStr); // re-check local / re-download URL
432
599
  } catch (e) {
433
- console.log(`Input not found, skipping: ${pin.ref.originalName ?? inputStr} (${e.message})`);
434
- continue;
600
+ // Server-side work (re-downloads, pending lip-sync) can still proceed without the original;
601
+ // only parts that would need re-cutting/re-uploading are skipped below.
602
+ console.log(`Original not available: ${pin.ref.originalName ?? inputStr ?? 'input'} (${e.message}) — continuing with server-side results only.`);
435
603
  }
436
- const inp = { ...prepared, originalName: prepared.originalName ?? pin.ref.originalName };
437
- const localPath = prepared.localPath ?? prepared.path ?? null;
604
+ const inp = prepared
605
+ ? { ...prepared, originalName: prepared.originalName ?? pin.ref.originalName }
606
+ : { source: pin.ref.source ?? 'local', localPath: pin.ref.localPath ?? null, originalName: pin.ref.originalName ?? null };
607
+ const localPath = prepared ? (prepared.localPath ?? prepared.path ?? null) : null;
438
608
  const materialize = async (c) => {
439
609
  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.');
610
+ if (!localPath) throw new Error('Original not found — cannot resume this part.');
441
611
  if (c.endMs == null) return localPath; // if whole (not split), the original
442
612
  const mk = `${pin.inputId}|${c.index}`;
443
613
  if (!matCache.has(mk)) matCache.set(mk, await recutChunk(localPath, c.startMs, c.endMs));
444
614
  return matCache.get(mk);
445
615
  };
446
616
 
447
- // Completed parts ((inputId|index|target) OK/PASSTHROUGH) use re-download/original; the rest are resubmission targets (excluded from skip).
617
+ // Completed parts use re-download/original; LSWAIT/LSRUN continue the lip-sync chain from the recorded
618
+ // projectSeq (never re-submitting paid work); the rest are resubmission targets (excluded from skip).
448
619
  for (const c of pin.chunks) {
449
620
  for (const target of targets) {
450
621
  const k = `${pin.inputId}|${c.index}|${target}`;
451
622
  const d = m.done?.[k];
452
- if (d?.status === 'OK') {
623
+ const base = { inputId: pin.inputId, index: c.index, target };
624
+ const tag = `[input ${pin.inputId + 1}] part ${c.index + 1}(${target})`;
625
+ if (d?.status === 'OK' || d?.status === 'RUN') {
453
626
  const out = join(outDir, `dub_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
454
627
  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`);
628
+ const dl = await download(d.projectSeq, m.spaceSeq, { kind: c.kind, outPath: out, lipsync: !!d.lipsync });
629
+ downloaded.push({
630
+ ...base, status: 'OK', path: out, projectId: d.projectSeq, name: dl.fileName,
631
+ ...(d.lipsync ? { lipsync: true, dubProjectId: d.dubSeq ?? null } : {}),
632
+ ...(d.lipsyncFailed ? { lipsyncFailed: true } : {}),
633
+ });
634
+ log(`${tag} re-downloaded`);
458
635
  skip.add(k);
459
636
  } catch {
460
637
  // Not downloadable (yet). Only a confirmed server-side failure gets re-dubbed; otherwise keep the
@@ -462,15 +639,61 @@ async function runResume(file) {
462
639
  let failedOnServer = false;
463
640
  try { failedOnServer = (await getStatus(d.projectSeq, m.spaceSeq)).state === 'failed'; } catch { /* unknown → keep waiting */ }
464
641
  if (failedOnServer) {
465
- log(`[input ${pin.inputId + 1}] part ${c.index + 1}(${target}) failed on the server — will re-dub`);
642
+ log(`${tag} failed on the server — will re-dub`);
643
+ } else {
644
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.projectSeq, reason: 'download_failed', ...(d.lipsync ? { lipsync: true, dubProjectId: d.dubSeq ?? null } : {}) });
645
+ log(`${tag} not ready/download failed — resume again later (no re-dub)`);
646
+ skip.add(k);
647
+ }
648
+ }
649
+ } else if (d?.status === 'LSWAIT') {
650
+ // Dubbing project recorded; lip-sync still owed for this part.
651
+ let s = null;
652
+ try { s = await getStatus(d.projectSeq, m.spaceSeq); } catch { /* unknown → treat as still processing */ }
653
+ if (s?.state === 'complete') {
654
+ pool.push({ inputId: pin.inputId, index: c.index, stage: 'lipsync', parentSeq: d.projectSeq, target, kind: c.kind, startMs: c.startMs, endMs: c.endMs });
655
+ log(`${tag} dubbed — lip-sync will be requested`);
656
+ skip.add(k);
657
+ } else if (s?.state === 'failed') {
658
+ log(`${tag} dubbing failed on the server — will re-dub`);
659
+ } else {
660
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.projectSeq, lipsyncPending: true, reason: 'dub_processing' });
661
+ log(`${tag} dubbing still processing on the server — resume again later`);
662
+ skip.add(k);
663
+ }
664
+ } else if (d?.status === 'LSRUN') {
665
+ // Lip-sync already submitted — never submit again (it would generate and bill again): download, wait, or fall back.
666
+ const out = join(outDir, `lip_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
667
+ try {
668
+ const dl = await download(d.projectSeq, m.spaceSeq, { lipsync: true, outPath: out });
669
+ downloaded.push({ ...base, status: 'OK', path: out, projectId: d.projectSeq, lipsync: true, dubProjectId: d.dubSeq ?? null, name: dl.fileName });
670
+ log(`${tag} lip-sync re-downloaded`);
671
+ skip.add(k);
672
+ } catch {
673
+ let s = null;
674
+ try { s = await getStatus(d.projectSeq, m.spaceSeq); } catch { /* unknown → keep waiting */ }
675
+ if (s?.state === 'failed' && d.dubSeq != null) {
676
+ const out2 = join(outDir, `dub_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
677
+ try {
678
+ const dl = await download(d.dubSeq, m.spaceSeq, { kind: c.kind, outPath: out2 });
679
+ downloaded.push({ ...base, status: 'OK', path: out2, projectId: d.dubSeq, lipsyncFailed: true, reason: s.message ?? 'lipsync_failed', name: dl.fileName });
680
+ log(`${tag} lip-sync failed — dubbed video delivered instead`);
681
+ } catch {
682
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.dubSeq, lipsyncFailed: true, reason: 'download_failed' });
683
+ }
684
+ skip.add(k);
466
685
  } 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)`);
686
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.projectSeq, lipsync: true, dubProjectId: d.dubSeq ?? null, reason: 'download_failed' });
687
+ log(`${tag} lip-sync still processing — resume again later`);
469
688
  skip.add(k);
470
689
  }
471
690
  }
472
691
  } else if (d?.status === 'PASSTHROUGH') {
473
- downloaded.push({ inputId: pin.inputId, index: c.index, target, status: 'PASSTHROUGH', path: await materialize(c) });
692
+ try {
693
+ downloaded.push({ ...base, status: 'PASSTHROUGH', path: await materialize(c) });
694
+ } catch (e) {
695
+ log(`${tag} silent part needs the original video — left out of the merge (${e.message})`);
696
+ }
474
697
  skip.add(k);
475
698
  }
476
699
  }
@@ -478,11 +701,20 @@ async function runResume(file) {
478
701
 
479
702
  // Re-cut only parts that have at least one incomplete language and add them to the pool (languages are filtered out via skip).
480
703
  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 });
704
+ const missing = targets.filter((t) => !skip.has(`${pin.inputId}|${c.index}|${t}`));
705
+ if (!missing.length) continue;
706
+ if (c.parentSeq != null) {
707
+ // lipsync-only plan interrupted before submission — the dubbed project is known, only lip-sync is owed
708
+ for (const t of missing) pool.push({ inputId: pin.inputId, index: c.index, stage: 'lipsync', parentSeq: c.parentSeq, target: t, kind: c.kind ?? 'video', startMs: c.startMs, endMs: c.endMs });
709
+ } else if (c.source === 'external') {
710
+ pool.push({ inputId: pin.inputId, index: c.index, source: 'external', sourceUrl: c.sourceUrl, kind: c.kind });
711
+ } else {
712
+ try {
713
+ const path = await materialize(c);
714
+ 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 });
715
+ } catch (e) {
716
+ log(`[input ${pin.inputId + 1}] part ${c.index + 1} needs the original video to be re-dubbed — skipped (${e.message})`);
717
+ }
486
718
  }
487
719
  }
488
720
  perInput.push({ inputId: pin.inputId, inp, ref: pin.ref, chunks: pin.chunks });
@@ -490,23 +722,314 @@ async function runResume(file) {
490
722
 
491
723
  if (pool.length) notify('Translating (resume)');
492
724
  const sched = pool.length
493
- ? await runSchedule(pool, m.spaceSeq, { source: m.opts?.source ?? 'auto', targets, done: skip }, { log, onResult: saver.onResult })
725
+ ? await runSchedule(pool, m.spaceSeq, { source: m.opts?.source ?? 'auto', targets, done: skip, lipsync }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit })
494
726
  : { results: new Map(), stopped: false, pendingLeft: [] };
495
727
 
496
728
  await finishPool([...downloaded, ...sched.results.values()], perInput, { ...ctx, sched });
497
729
  }
498
730
 
731
+ // Lip-sync an already-dubbed project set: --lipsync-only "<project-ref JSON | seq[,seq...]>".
732
+ // Nothing is uploaded or re-dubbed — each part's dubbed project gets a lip-sync request (billed by the
733
+ // server as a new generation), then the results are downloaded and merged like a normal run.
734
+ async function runLipsyncOnly(args) {
735
+ await ensureKey();
736
+ const raw = String(args.lipsyncOnly).trim();
737
+ let ref;
738
+ if (raw.startsWith('{')) {
739
+ try { ref = JSON.parse(raw); } catch { throw new UsageError('Invalid --lipsync-only value — pass the [project-ref] JSON or a projectSeq list.'); }
740
+ } else if (/^\d+(\s*,\s*\d+)*$/.test(raw)) {
741
+ ref = { parts: raw.split(',').map((s) => ({ seq: Number(s.trim()) })) };
742
+ } else {
743
+ throw new UsageError('Invalid --lipsync-only value — pass the [project-ref] JSON or a projectSeq list.');
744
+ }
745
+ const parts = Array.isArray(ref.parts) ? ref.parts : [];
746
+ if (!parts.some((p) => p?.seq != null)) throw new UsageError('No dubbed project found in the --lipsync-only reference.');
747
+
748
+ const target = ref.lang ?? 'out';
749
+ const localPath = ref.input && !/^[a-z]+:\/\//i.test(ref.input) ? ref.input : null;
750
+ const inp = { source: 'local', localPath, originalName: localPath ? basename(localPath) : ref.input ?? null };
751
+ const file = resumePath({ out: args.out, inputs: [inp], multiInput: false });
752
+ guardExistingState(file); // an interrupted earlier run owns this state file — resume it instead of re-billing
753
+ const spaceSeq = Number(ref.space) || await ensureSpace(args);
754
+ const ctx = { spaceSeq, source: 'auto', targets: [target], out: args.out, multiInput: false, file, prevDone: {}, lipsync: true };
755
+
756
+ const chunks = parts.map((p, i) => ({
757
+ index: i, source: 'local',
758
+ startMs: p?.ms?.[0] ?? p?.pt?.[0] ?? null, endMs: p?.ms?.[1] ?? p?.pt?.[1] ?? null,
759
+ kind: 'video', ...(p?.seq != null ? { parentSeq: p.seq } : {}),
760
+ }));
761
+ const perInput = [{ inputId: 0, inp, ref: { source: 'local', localPath, originalName: inp.originalName }, chunks }];
762
+ const saver = manifestSaver(ctx, perInput);
763
+
764
+ const downloaded = [];
765
+ const pool = [];
766
+ for (const c of chunks) {
767
+ if (c.parentSeq == null) {
768
+ // silent segment of the original run — rebuild it from the original video for the merge
769
+ if (!localPath) { console.error('This reference contains silent segments — the original video path is required to rebuild them.'); throw new ExitCode(1); }
770
+ const r = { inputId: 0, index: c.index, target, status: 'PASSTHROUGH', path: await recutChunk(localPath, c.startMs, c.endMs) };
771
+ downloaded.push(r);
772
+ saver.onResult(r);
773
+ } else {
774
+ pool.push({ inputId: 0, index: c.index, stage: 'lipsync', parentSeq: c.parentSeq, target, kind: 'video', startMs: c.startMs, endMs: c.endMs });
775
+ }
776
+ }
777
+ saver.writeNow();
778
+ notify('Requesting lip-sync for the existing dubbed project — no re-dubbing, no re-upload.');
779
+ const sched = await runSchedule(pool, spaceSeq, { targets: [target], lipsync: true }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit });
780
+ await finishPool([...downloaded, ...sched.results.values()], perInput, { ...ctx, sched });
781
+ }
782
+
783
+ // ── audio separation (--separate) ─────────────────────────
784
+ // Voice/background separation — no languages involved. Chunks are processed sequentially (one queue slot),
785
+ // long/large sources reuse the same auto-split, and per-track pieces are merged back like dubbed parts.
786
+ // Billing ≈ 0.5 credit per second (server authoritative). Interrupted runs are not resumable yet (re-run re-bills).
787
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
788
+ const SEPARATION_TRACKS = ['voice', 'background', 'sub_background'];
789
+
790
+ // Separation resume state (version 1, kind 'separation'): the chunk plan + a per-chunk projectId checkpoint.
791
+ // Mirrors the dubbing manifest but keyed by inputId|chunkIndex (no target language).
792
+ function buildSepManifest(ctx, perInput, done) {
793
+ return {
794
+ version: 1, kind: 'separation', spaceSeq: ctx.spaceSeq, out: ctx.out ?? null,
795
+ inputs: perInput.map((p) => ({
796
+ inputId: p.inputId, ref: p.ref,
797
+ chunks: p.chunks.map((c) => ({
798
+ index: c.index, source: c.source, sourceUrl: c.sourceUrl ?? null,
799
+ startMs: c.startMs ?? null, endMs: c.endMs ?? null, title: c.title ?? null, kind: c.kind ?? null,
800
+ })),
801
+ })),
802
+ done, // `${inputId}|${index}` → { status:'RUN'|'OK'|'HARD_FAIL', projectId?, reason? }
803
+ };
804
+ }
805
+ function sepSaver(ctx, perInput, prevDone = {}) {
806
+ const done = { ...prevDone };
807
+ const writeNow = () => {
808
+ try {
809
+ mkdirSync(dirname(ctx.file), { recursive: true });
810
+ const tmp = ctx.file + '.tmp';
811
+ writeFileSync(tmp, JSON.stringify(buildSepManifest(ctx, perInput, done)), 'utf8');
812
+ renameSync(tmp, ctx.file);
813
+ } catch { /* best-effort — saving state must never break the run */ }
814
+ };
815
+ return {
816
+ writeNow, done,
817
+ // The paid projectId is persisted the moment it exists → a hard kill can't cause a re-submission on resume.
818
+ onSubmit: (inputId, index, projectId) => { done[`${inputId}|${index}`] = { status: 'RUN', projectId }; writeNow(); },
819
+ onComplete: (inputId, index, projectId) => { done[`${inputId}|${index}`] = { status: 'OK', projectId }; writeNow(); },
820
+ onFail: (inputId, index, reason) => { done[`${inputId}|${index}`] = { status: 'HARD_FAIL', reason }; writeNow(); },
821
+ };
822
+ }
823
+
824
+ async function runSeparation(args) {
825
+ await ensureKey();
826
+ const inputs = await expandInputs(args.inputs, { recursive: args.recursive });
827
+ const multiInput = inputs.length > 1;
828
+ const file = resumePath({ out: args.out, inputs, multiInput });
829
+ guardExistingState(file); // block re-running the original; --resume continues without re-billing submitted parts
830
+ const spaceSeq = await ensureSpace(args);
831
+ const ctx = { spaceSeq, out: args.out ?? null, file };
832
+ const perInput = [];
833
+ const saver = sepSaver(ctx, perInput);
834
+ // Phase 1 — resolve chunk plans (split-confirm happens here) and persist them before any submission.
835
+ for (let id = 0; id < inputs.length; id++) {
836
+ const inp = inputs[id];
837
+ let chunks;
838
+ try {
839
+ ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
840
+ } catch (e) {
841
+ if (e?.name === 'SplitConfirmNeeded') {
842
+ console.log(splitConfirmMessage(e.details, multiInput ? labelOf(inp) : null, 'separate'));
843
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* nothing billed yet → safe to discard */ }
844
+ throw new ExitCode(3);
845
+ }
846
+ if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; }
847
+ notify(`Could not prepare: ${labelOf(inp)} — ${friendlyError(e)}`); continue;
848
+ }
849
+ if (chunks.length > 1) notify(`Split complete — ${labelOf(inp)} (${chunks.length} parts)`);
850
+ perInput.push({ inputId: id, inp, ref: refOf(inp), chunks });
851
+ saver.writeNow();
852
+ }
853
+ if (!perInput.length) { notify('No inputs to separate.'); return; }
854
+ notify('Separating voice/background');
855
+ // Phase 2 — submit (checkpointed) → wait → download → merge → save.
856
+ const pending = await separationProcess(perInput, spaceSeq, ctx, saver, null);
857
+ finishSepState(file, pending);
858
+ }
859
+
860
+ // Drop the state file only when nothing paid is still owed; otherwise keep it and point at --resume (no re-billing).
861
+ function finishSepState(file, pending) {
862
+ if (pending) { notify(`Some parts still need downloading — resume with: node scripts/dubbing.mjs --resume "${file}"`); return; }
863
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
864
+ }
865
+
866
+ // Resume: reload the chunk plan, re-cut/re-upload only the parts never submitted, poll/re-download the submitted ones.
867
+ async function runResumeSeparation(m, file) {
868
+ if (m.version !== 1) throw new Error('Unsupported separation state-file format — run again from the original.');
869
+ const spaceSeq = m.spaceSeq;
870
+ const ctx = { spaceSeq, out: m.out ?? null, file };
871
+ const perInput = [];
872
+ const recutCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part)
873
+ for (const pin of m.inputs) {
874
+ const inputStr = pin.ref.source === 'local' ? pin.ref.localPath : pin.ref.sourceUrl;
875
+ let localPath = null; // null when the original can't be re-prepared → materializeFor's guard fires cleanly (server-side parts still resume)
876
+ try { const prepared = await prepareInput(inputStr); localPath = prepared.localPath ?? prepared.path ?? null; }
877
+ catch (e) { console.log(`Original not available: ${pin.ref.originalName ?? inputStr ?? 'input'} (${e.message}) — server-side results only.`); }
878
+ perInput.push({ inputId: pin.inputId, ref: pin.ref, inp: pin.ref, chunks: pin.chunks, _localPath: localPath });
879
+ }
880
+ const saver = sepSaver(ctx, perInput, m.done ?? {});
881
+ const materializeFor = async (pin, c) => {
882
+ if (c.source === 'external') return null; // can't re-cut → upload the source URL
883
+ if (!pin._localPath) throw new Error('Original not found — cannot resume this part.');
884
+ if (c.endMs == null) return pin._localPath; // whole (unsplit) → the original
885
+ const mk = `${pin.inputId}|${c.index}`;
886
+ if (!recutCache.has(mk)) recutCache.set(mk, await recutChunk(pin._localPath, c.startMs, c.endMs));
887
+ return recutCache.get(mk);
888
+ };
889
+ const pending = await separationProcess(perInput, spaceSeq, ctx, saver, materializeFor);
890
+ finishSepState(file, pending);
891
+ }
892
+
893
+ // Per-input: separate every chunk (checkpointing each submission), merge tracks, save. Shared by run + resume.
894
+ // Returns true when some paid part is still owed (undelivered) → the caller must keep the state file for resume.
895
+ async function separationProcess(perInput, spaceSeq, ctx, saver, materializeFor) {
896
+ const usedByDir = new Map();
897
+ const lines = [];
898
+ let ok = 0, fail = 0;
899
+ const flags = { pending: false };
900
+ const tmp = await makeTempDir('dubbing-sep-');
901
+ for (const pin of perInput) {
902
+ try {
903
+ const byTrack = await separateChunks(pin, spaceSeq, tmp, saver, materializeFor, flags);
904
+ const line = await saveSeparationTracks(pin, byTrack, { out: ctx.out, usedByDir });
905
+ lines.push(line);
906
+ if (line.startsWith('Done')) ok++; else fail++;
907
+ } catch (e) {
908
+ if (e?.httpStatus === 402) { // out of credits — deliver what finished + top-up/resume path, then stop
909
+ if (lines.length) console.log('\n' + lines.join('\n'));
910
+ const plan = await getPlanStatus(spaceSeq);
911
+ console.log(messages.quotaExceeded({ planTier: plan?.planTier, remainingQuota: plan?.remainingQuota, resumeHint: `node scripts/dubbing.mjs --resume "${ctx.file}"` }));
912
+ throw new ExitCode(1);
913
+ }
914
+ if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(pin.inp ?? pin.ref), e)); continue; }
915
+ flags.pending = true; // errored after some chunks may have been billed → keep state so resume re-downloads them
916
+ fail++;
917
+ lines.push(`Could not separate: ${labelOf(pin.inp ?? pin.ref)} — ${friendlyError(e)}`);
918
+ }
919
+ }
920
+ console.log('\n' + lines.join('\n'));
921
+ if (perInput.length > 1) console.log(`\nSummary: ${ok} done · ${fail} failed`);
922
+ return flags.pending;
923
+ }
924
+
925
+ // Separate one input's chunks. A chunk with a recorded projectId is polled/re-downloaded (no re-submit); the rest are
926
+ // (re-cut/uploaded and) submitted, checkpointing the projectId the instant it exists.
927
+ async function separateChunks(pin, spaceSeq, tmp, saver, materializeFor, flags) {
928
+ const byTrack = new Map(SEPARATION_TRACKS.map((t) => [t, []]));
929
+ const gap = (index, reason) => { for (const t of SEPARATION_TRACKS) byTrack.get(t).push({ index, status: 'HARD_FAIL', reason }); };
930
+ for (const c of pin.chunks) {
931
+ const prev = saver.done[`${pin.inputId}|${c.index}`];
932
+ if (prev?.status === 'HARD_FAIL') { gap(c.index, prev.reason ?? 'failed'); continue; }
933
+ let projectId = prev?.projectId ?? null;
934
+ try {
935
+ if (projectId == null) { // never submitted → (re-cut/upload and) submit
936
+ let mediaSeq = c.mediaSeq ?? null, kind = c.kind ?? 'video';
937
+ if (mediaSeq == null) {
938
+ const cut = materializeFor ? await materializeFor(pin, c) : null;
939
+ const ref = cut ? { source: 'local', localPath: cut, originalName: basename(cut) } // basename keeps the extension (c.title has none)
940
+ : (c.source === 'external' ? { source: 'external', sourceUrl: c.sourceUrl } : refOf(pin.inp ?? pin.ref));
941
+ ({ seq: mediaSeq, kind } = await upload(ref, spaceSeq));
942
+ }
943
+ ([projectId] = await requestAudioSeparation(spaceSeq, mediaSeq, { title: c.title, kind }));
944
+ if (projectId == null) throw new Error('Separation request was not accepted.');
945
+ saver.onSubmit(pin.inputId, c.index, projectId); // checkpoint the billed unit before any wait
946
+ if (pin.chunks.length > 1) log(`part ${c.index + 1}/${pin.chunks.length}: separation submitted`);
947
+ }
948
+ if (prev?.status !== 'OK') { // RUN (resume) or freshly submitted → wait for completion
949
+ const st = await waitForProject(projectId, spaceSeq);
950
+ if (st.state !== 'complete') {
951
+ if (st.timedOut) { flags.pending = true; gap(c.index, st.message ?? 'timed out'); continue; } // may still be running (paid) → keep the RUN checkpoint so resume re-polls it
952
+ saver.onFail(pin.inputId, c.index, st.message ?? st.failureReason ?? 'failed'); // genuine server failure → terminal
953
+ gap(c.index, st.message ?? st.failureReason ?? 'failed');
954
+ continue;
955
+ }
956
+ saver.onComplete(pin.inputId, c.index, projectId);
957
+ }
958
+ const tracks = await downloadSeparation(projectId, spaceSeq, (label, ext) => join(tmp, `sep_${pin.inputId}_${c.index}_${label}${ext}`));
959
+ for (const t of tracks) byTrack.get(t.label)?.push({ index: c.index, status: 'OK', path: t.path, name: t.fileName });
960
+ } catch (e) {
961
+ if (e?.httpStatus === 402) throw e; // credit-out → let separationProcess deliver finished parts + surface the resume path
962
+ // One part failed to prepare/upload/submit/download — gap it so finished sibling parts still merge & save; keep state so resume retries.
963
+ flags.pending = true;
964
+ gap(c.index, friendlyError(e));
965
+ }
966
+ }
967
+ return byTrack;
968
+ }
969
+
970
+ async function saveSeparationTracks(pin, byTrack, { out, usedByDir }) {
971
+ const inp = pin.inp ?? pin.ref;
972
+ const dir = out ?? inputSaveDir(inp); // --separate treats --out as a folder (three tracks per input)
973
+ mkdirSync(dir, { recursive: true });
974
+ const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
975
+ const saved = [];
976
+ let excluded = false;
977
+ for (const t of SEPARATION_TRACKS) {
978
+ const results = byTrack.get(t);
979
+ if (!results.some((r) => r.status === 'OK')) continue;
980
+ const { outputs, report } = await mergeGroups(results);
981
+ if (report) excluded = true;
982
+ for (let i = 0; i < outputs.length; i++) {
983
+ const o = outputs[i];
984
+ const ext = extname(o.name ?? o.path ?? '') || '.wav';
985
+ const name = reserve(dir, `${stem}.${t}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`, usedByDir);
986
+ copyFileSync(o.path, join(dir, name));
987
+ saved.push(name);
988
+ }
989
+ }
990
+ if (!saved.length) {
991
+ const why = byTrack.get('voice').find((r) => r.reason)?.reason ?? 'no result';
992
+ return `Could not separate: ${labelOf(inp)} — ${why}`;
993
+ }
994
+ return `Done (separation): ${labelOf(inp)} → ${saved.join(', ')}${excluded ? ' (some parts excluded)' : ''}`;
995
+ }
996
+
997
+ // Poll a project until it settles. Progress changes reset the idle window; a stuck job times out.
998
+ async function waitForProject(projectSeq, spaceSeq) {
999
+ let last = -1;
1000
+ let lastChange = Date.now();
1001
+ for (;;) {
1002
+ let st = null;
1003
+ try { st = await getStatus(projectSeq, spaceSeq); } catch { /* transient — keep polling */ }
1004
+ if (st?.state === 'complete' || st?.state === 'failed') return st;
1005
+ const p = st?.progress ?? -1;
1006
+ if (p !== last) { last = p; lastChange = Date.now(); if (p > 0) log(`separating... ${p}%`); }
1007
+ if (Date.now() - lastChange > MAX_IDLE_MS) return { state: 'failed', timedOut: true, message: 'timed out (no progress)' };
1008
+ await sleep(POLL_INTERVAL_MS);
1009
+ }
1010
+ }
1011
+
499
1012
  // Pure helper exports for testing (when run directly, only main below executes).
500
- export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes };
1013
+ export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes, guardExistingState, splitConfirmMessage, buildSepManifest, sepSaver };
501
1014
 
502
1015
  async function main() {
503
1016
  let exitCode = 0;
1017
+ let updateNotice = null; // daily version check, kicked off in the background and printed after the work finishes
504
1018
  try {
505
1019
  preloadKeyEnv(); // pre-inject the key into env before async (at a clean point) → avoid a synchronous powershell call/crash in the main process
506
1020
  const args = parseArgs(process.argv.slice(2));
1021
+ if (!args.help) updateNotice = checkForUpdate().catch(() => null); // non-blocking; never fails the run
507
1022
  if (args.help) console.log(USAGE);
508
1023
  else if (args.resume) await runResume(args.resume);
509
- else if (!args.inputs.length) {
1024
+ else if (args.separate) {
1025
+ if (args.lipsync || args.lipsyncOnly) throw new UsageError('Use --separate on its own — it cannot be combined with lip-sync options.');
1026
+ if (!args.inputs.length) { console.error(USAGE); exitCode = 1; }
1027
+ else await runSeparation(args);
1028
+ } else if (args.lipsyncOnly) {
1029
+ if (args.inputs.length) throw new UsageError('--lipsync-only takes no input files (the project reference identifies them).');
1030
+ if (args.lipsync) throw new UsageError('Use either --lipsync (dub + lip-sync) or --lipsync-only, not both.');
1031
+ await runLipsyncOnly(args);
1032
+ } else if (!args.inputs.length) {
510
1033
  console.error(USAGE);
511
1034
  exitCode = 1;
512
1035
  } else await runPool(args);
@@ -517,6 +1040,8 @@ async function main() {
517
1040
  } finally {
518
1041
  await cleanupTempDirs(); // bulk-clean the cut/schedule/merge/download temp folders
519
1042
  }
1043
+ // After the work is done (never mid-job), surface a version-update notice if one is available.
1044
+ if (updateNotice) { try { const n = await updateNotice; if (n) console.log('\n' + n); } catch { /* ignore */ } }
520
1045
  // Natural exit (loop drain) — process.exit() while fetch sockets are closing hits a Windows libuv assert
521
1046
  // (async.c) that corrupts the exit code. The unref'd timer is a hard-exit fallback if a handle ever hangs.
522
1047
  process.exitCode = exitCode;