perso-dubbing 0.2.0 → 0.3.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';
@@ -15,10 +15,12 @@ import { dubbingSpaces, getPlanStatus } from '../lib/space.mjs';
15
15
  import { getLanguages } from '../lib/languages.mjs';
16
16
  import { resolveChunks, recutChunk } 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
21
  import { cleanupTempDirs, makeTempDir } from '../lib/tmp.mjs';
22
+ import { probe } from '../lib/ffmpeg.mjs';
23
+ 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
24
 
23
25
  const log = (m) => console.error(' ' + m); // background verbose log (stderr)
24
26
  // Milestones exposed to the user (stdout). The agent relays these [progress] lines to chat per the SKILL rules.
@@ -111,8 +113,15 @@ async function ensureSpace(args) {
111
113
  }
112
114
 
113
115
  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]',
116
+ 'Usage: node scripts/dubbing.mjs "<file|folder|URL>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder] [--recursive] [--lipsync] [--force]',
117
+ ' node scripts/dubbing.mjs --lipsync-only "<project-ref JSON | projectSeq[,projectSeq...]>" [--space "space name"] [--out path|folder]',
118
+ ' node scripts/dubbing.mjs --separate "<file|folder|URL>" ["<another input>" ...] [--space "space name"] [--out folder]',
115
119
  ' node scripts/dubbing.mjs --resume "<state-file>"',
120
+ '',
121
+ ' --lipsync dub, then generate the lip-synced video (extra credits; takes much longer than dubbing)',
122
+ ' --lipsync-only lip-sync an already-dubbed project (uses the [project-ref] line printed by a finished run; no re-dub charge)',
123
+ ' --separate split the source into voice / background / sub-background audio tracks (no dubbing; ~0.5 credit per second)',
124
+ ' --force skip the upfront credit-estimate gate of --lipsync',
116
125
  ].join('\n');
117
126
 
118
127
  class UsageError extends Error {
@@ -128,11 +137,14 @@ class ExitCode extends Error {
128
137
 
129
138
  function parseArgs(argv) {
130
139
  const a = { source: 'auto', target: 'en', inputs: [] };
131
- const VALUE_FLAGS = { '--resume': 'resume', '--source': 'source', '--target': 'target', '--space': 'space', '--out': 'out' };
140
+ const VALUE_FLAGS = { '--resume': 'resume', '--source': 'source', '--target': 'target', '--space': 'space', '--out': 'out', '--lipsync-only': 'lipsyncOnly' };
132
141
  for (let i = 0; i < argv.length; i++) {
133
142
  const t = argv[i];
134
143
  if (t === '--help' || t === '-h') a.help = true;
135
144
  else if (t === '--recursive') a.recursive = true;
145
+ else if (t === '--separate') a.separate = true;
146
+ else if (t === '--lipsync') a.lipsync = true;
147
+ else if (t === '--force') a.force = true;
136
148
  else if (t in VALUE_FLAGS) {
137
149
  const v = argv[++i];
138
150
  if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
@@ -193,6 +205,18 @@ function resumePath({ out, inputs, multiInput }) {
193
205
  return join(process.cwd(), 'dubbing-resume.json');
194
206
  }
195
207
 
208
+ // A state file at the target path means an earlier run for this input never finished (it is deleted on
209
+ // success). Starting fresh would overwrite it and re-bill work the server already completed, so stop before
210
+ // any network/billing and hand the choice to the user (exit 3 = "ask the user", like [space-select]).
211
+ function guardExistingState(file) {
212
+ if (!existsSync(file)) return;
213
+ console.log(`[resume-check] An earlier run for this input did not finish — its state file still exists: ${file}`);
214
+ console.log('[resume-check] To finish it without paying again for the completed parts, run:');
215
+ console.log(`[resume-check] node scripts/dubbing.mjs --resume "${file}"`);
216
+ 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.');
217
+ throw new ExitCode(3);
218
+ }
219
+
196
220
  // --out single input: add a language suffix if multilingual (single language stays as-is).
197
221
  // The extension class excludes path separators — a dot in a FOLDER name must not be taken as the extension.
198
222
  function explicitOutPath(argOut, target, multiLang) {
@@ -220,10 +244,10 @@ function reserve(dir, name, usedByDir) {
220
244
  // Determine the final save path for one (input × language) bundle + create the directory (if needed).
221
245
  // 1) single input + --out → that file (_language if multilingual, _2,_3… if multiple outputs). [user-specified takes precedence]
222
246
  // 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).
247
+ // 3) otherwise (split merge, etc.) → <originalName>.<suffix>.<language>.<ext> (_2,_3… if multiple; suffix: dubbed|lipsync).
224
248
  // Save folder: --out (folder if multi-input) > next to the input original > current folder.
225
249
  function targetPaths(outputs, ctx) {
226
- const { inp, target, isSplit, multiInput, multiLang, out, usedByDir } = ctx;
250
+ const { inp, target, isSplit, multiInput, multiLang, out, usedByDir, suffix = 'dubbed' } = ctx;
227
251
  if (out && !multiInput) {
228
252
  const file = explicitOutPath(out, target, multiLang);
229
253
  mkdirSync(dirname(file), { recursive: true });
@@ -238,7 +262,7 @@ function targetPaths(outputs, ctx) {
238
262
  const ext = extname(outputs[0]?.name || outputs[0]?.path || '') || '.mp4';
239
263
  const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
240
264
  outputs.forEach((_, i) => {
241
- const base = `${stem}.dubbed.${target}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`;
265
+ const base = `${stem}.${suffix}.${target}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`;
242
266
  names.push(reserve(dir, base, usedByDir));
243
267
  });
244
268
  }
@@ -246,13 +270,22 @@ function targetPaths(outputs, ctx) {
246
270
  }
247
271
 
248
272
  // Persistable completion entry for one result — what resume needs to skip/re-download. null = nothing worth keeping.
273
+ // OK{projectSeq} → final project (add lipsync:1 [+dubSeq] when the deliverable is the lip-sync video)
274
+ // OK{…, lipsyncFailed:1} → lip-sync failed permanently; the dubbed video is the deliverable (never retried)
275
+ // LSWAIT{projectSeq} → dubbing project exists, lip-sync still owed (resume runs only the lip-sync)
249
276
  function doneEntry(r) {
250
- if (r.status === 'OK' || r.status === 'DLFAIL') return { status: 'OK', projectSeq: r.projectId }; // DLFAIL: generated → re-download on resume
277
+ if (r.status === 'OK' || r.status === 'DLFAIL') { // DLFAIL: generated → re-download on resume
278
+ if (r.lipsyncPending) return { status: 'LSWAIT', projectSeq: r.projectId };
279
+ const e = { status: 'OK', projectSeq: r.projectId };
280
+ if (r.lipsync) { e.lipsync = 1; if (r.dubProjectId != null) e.dubSeq = r.dubProjectId; }
281
+ if (r.lipsyncFailed) e.lipsyncFailed = 1;
282
+ return e;
283
+ }
251
284
  if (r.status === 'PASSTHROUGH') return { status: 'PASSTHROUGH' };
252
285
  return null;
253
286
  }
254
287
 
255
- // Lightweight manifest (v4) holding the chunk plan (boundaries) + only the completion status per (input|part|language).
288
+ // Lightweight manifest (v5) holding the chunk plan (boundaries) + only the completion status per (input|part|language).
256
289
  function buildManifest(ctx, perInput, results, prevDone = {}) {
257
290
  const done = { ...prevDone };
258
291
  for (const r of results) {
@@ -260,12 +293,14 @@ function buildManifest(ctx, perInput, results, prevDone = {}) {
260
293
  if (e) done[`${r.inputId}|${r.index}|${r.target}`] = e;
261
294
  }
262
295
  return {
263
- version: 4, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
296
+ version: 5, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
297
+ lipsync: !!ctx.lipsync,
264
298
  inputs: perInput.map((p) => ({
265
299
  inputId: p.inputId, ref: p.ref,
266
300
  chunks: p.chunks.map((c) => ({
267
301
  index: c.index, source: c.source, sourceUrl: c.sourceUrl ?? null,
268
302
  startMs: c.startMs ?? null, endMs: c.endMs ?? null, title: c.title ?? null, kind: c.kind ?? null,
303
+ ...(c.parentSeq != null ? { parentSeq: c.parentSeq } : {}), // lipsync-only: the dubbed project behind this part
269
304
  })),
270
305
  })),
271
306
  done,
@@ -285,10 +320,21 @@ function manifestSaver(ctx, perInput, prevDone = {}) {
285
320
  } catch { /* best-effort — saving state must never break the run */ }
286
321
  };
287
322
  const onResult = (r) => {
323
+ const k = `${r.inputId}|${r.index}|${r.target}`;
288
324
  const e = doneEntry(r);
289
- if (e) { done[`${r.inputId}|${r.index}|${r.target}`] = e; writeNow(); }
325
+ if (e) { done[k] = e; writeNow(); }
326
+ else if (done[k]) { delete done[k]; writeNow(); } // hard failure after a submit checkpoint → drop the stale entry
327
+ };
328
+ // Submission checkpoint: the paid projectSeq is persisted the moment it exists, so even a hard kill
329
+ // before any result cannot cause a re-submission (= double billing) on resume.
330
+ const onSubmit = (s) => {
331
+ const k = `${s.inputId}|${s.index}|${s.target}`;
332
+ if (s.stage === 'lipsync') done[k] = { status: 'LSRUN', projectSeq: s.projectId, dubSeq: s.parentSeq };
333
+ else if (s.lipsync) done[k] = { status: 'LSWAIT', projectSeq: s.projectId }; // chain: this dub still owes a lip-sync
334
+ else done[k] = { status: 'RUN', projectSeq: s.projectId };
335
+ writeNow();
290
336
  };
291
- return { writeNow, onResult };
337
+ return { writeNow, onResult, onSubmit };
292
338
  }
293
339
 
294
340
  // Sum of remaining (unprocessed) chunk durations → minutes (rounded up). null if only boundary-less chunks exist.
@@ -300,6 +346,28 @@ function remainingMinutes(chunks) {
300
346
  return ms > 0 ? Math.ceil(ms / 60000) : null;
301
347
  }
302
348
 
349
+ // Machine-readable project reference, one line per delivered (input × language). Lets the agent lip-sync
350
+ // this exact dubbing later in the session (--lipsync-only) without re-dubbing. Not meant for end users.
351
+ function emitProjectRef(pin, tRes, target, ctx, { lipsync }) {
352
+ const parts = [];
353
+ for (const c of pin.chunks) {
354
+ const r = tRes.find((x) => x.index === c.index);
355
+ if (!r) return;
356
+ if (r.status === 'PASSTHROUGH') {
357
+ parts.push({ pt: [c.startMs ?? 0, c.endMs ?? 0] });
358
+ } else if (r.status === 'OK' || r.status === 'DLFAIL') {
359
+ const dubSeq = r.lipsync ? r.dubProjectId : r.projectId; // always reference the dubbing project (lip-sync reuse needs it)
360
+ if (dubSeq == null) return;
361
+ const part = { seq: dubSeq };
362
+ if (Number.isFinite(c.startMs) && Number.isFinite(c.endMs)) part.ms = [c.startMs, c.endMs];
363
+ parts.push(part);
364
+ } else return;
365
+ }
366
+ if (!parts.some((p) => p.seq != null)) return;
367
+ const input = pin.ref?.localPath ?? pin.ref?.sourceUrl ?? null;
368
+ console.log(`[project-ref] ${JSON.stringify({ v: 1, space: ctx.spaceSeq, input, lang: target, parts, lipsync: !!lipsync })}`);
369
+ }
370
+
303
371
  // 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
372
  // ctx: { spaceSeq, source, targets, out, multiInput, sched, file, prevDone }
305
373
  async function finishPool(allResults, perInput, ctx) {
@@ -322,18 +390,27 @@ async function finishPool(allResults, perInput, ctx) {
322
390
  failCount++;
323
391
  continue;
324
392
  }
393
+ const hasLs = mergeable.some((r) => r.lipsync);
394
+ const lsFailed = mergeable.some((r) => r.lipsyncFailed);
395
+ const lsPending = mergeable.some((r) => r.lipsyncPending);
325
396
  if (mergeable.length > 1) notify(`Merging — ${labelOf(pin.inp)}${multiLang ? ` (${target})` : ''}`);
326
397
  const { outputs, report } = await mergeGroups(tRes);
327
398
  let saved = [];
328
399
  if (outputs.length) {
329
- const paths = targetPaths(outputs, { inp: pin.inp, target, isSplit, multiInput: ctx.multiInput, multiLang, out: ctx.out, usedByDir });
400
+ const suffix = hasLs ? 'lipsync' : 'dubbed';
401
+ const paths = targetPaths(outputs, { inp: pin.inp, target, isSplit, multiInput: ctx.multiInput, multiLang, out: ctx.out, usedByDir, suffix });
330
402
  outputs.forEach((o, i) => copyFileSync(o.path, paths[i]));
331
403
  await rm(dirname(outputs[0].path), { recursive: true, force: true }).catch(() => {}); // clean up merge temp folder
332
404
  saved = paths;
333
405
  }
334
406
  if (saved.length) {
335
- lines.push(`Done: ${tlab} ${saved.map((p) => basename(p)).join(', ')}${report ? ' (some parts excluded)' : ''}`);
407
+ const notes = [];
408
+ if (report) notes.push('some parts excluded');
409
+ if (lsFailed) notes.push(hasLs ? 'lip-sync failed on some parts — dubbed video used for those' : 'lip-sync failed — dubbed video delivered instead');
410
+ if (lsPending) notes.push('lip-sync still pending — resume to finish it');
411
+ lines.push(`Done${hasLs ? ' (lip-sync)' : ''}: ${tlab} → ${saved.map((p) => basename(p)).join(', ')}${notes.length ? ` (${notes.join('; ')})` : ''}`);
336
412
  okCount++;
413
+ emitProjectRef(pin, tRes, target, ctx, { lipsync: hasLs && !lsFailed && !lsPending });
337
414
  } else {
338
415
  lines.push(`Could not dub: ${tlab} — ${report ?? 'no result'}`);
339
416
  failCount++;
@@ -352,11 +429,13 @@ async function finishPool(allResults, perInput, ctx) {
352
429
  if (stopped) {
353
430
  const plan = await getPlanStatus(ctx.spaceSeq);
354
431
  const min = remainingMinutes(ctx.sched.pendingLeft);
432
+ const lsOwed = allResults.some((r) => r.lipsyncPending);
355
433
  console.log('\n' + messages.quotaExceeded({
356
434
  planTier: plan?.planTier,
357
435
  remainingQuota: plan?.remainingQuota,
358
436
  remainingNote: min != null ? `~${min} min` : null,
359
437
  resumeHint: `node scripts/dubbing.mjs --resume "${ctx.file}"`,
438
+ note: lsOwed ? ' Dubbing for some items is already done — resume will run only the remaining lip-sync (no re-dub charge).' : null,
360
439
  }));
361
440
  } else {
362
441
  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 +450,22 @@ async function runPool(args) {
371
450
  await ensureKey();
372
451
  const wantedTargets = String(args.target).split(',').map((t) => t.trim()).filter(Boolean); // --target en,ja,ko
373
452
  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 });
453
+ let inputs = await expandInputs(args.inputs, { recursive: args.recursive });
454
+ if (args.lipsync) {
455
+ // Lip-sync applies to video only — drop audio inputs up-front (before any upload).
456
+ const isAudioInput = (i) => AUDIO_EXT.test(i.originalName ?? i.localPath ?? '');
457
+ const audio = inputs.filter(isAudioInput);
458
+ if (audio.length && audio.length === inputs.length) { console.error('Lip-sync requires video input.'); throw new ExitCode(1); }
459
+ for (const a of audio) notify(`Skipped (lip-sync requires video): ${labelOf(a)}`);
460
+ inputs = inputs.filter((i) => !isAudioInput(i));
461
+ notify('Lip-sync requested — dubbing first, then lip-sync generation (this takes considerably longer than dubbing alone).');
462
+ }
377
463
  const multiInput = inputs.length > 1;
378
464
  const file = resumePath({ out: args.out, inputs, multiInput });
379
- const ctx = { spaceSeq, source, targets, out: args.out, multiInput, file, prevDone: {} };
465
+ guardExistingState(file); // before validate/space/upload never silently restart (and re-bill) an interrupted run
466
+ const { targets, source } = await validateLanguages(wantedTargets, args.source); // typo-fail before asking anything
467
+ const spaceSeq = await ensureSpace(args); // ask before any download/upload work (cheap to re-run with --space)
468
+ const ctx = { spaceSeq, source, targets, out: args.out, multiInput, file, prevDone: {}, lipsync: !!args.lipsync };
380
469
 
381
470
  // Per-input split/upload → tag every part with inputId into a single pool.
382
471
  const pool = [];
@@ -400,21 +489,62 @@ async function runPool(args) {
400
489
  }
401
490
  if (!pool.length) { notify('No inputs to process.'); return; }
402
491
 
492
+ if (args.lipsync && !args.force) await creditPreflight(perInput, spaceSeq, targets.length);
493
+
403
494
  notify(`Translating${targets.length > 1 ? ` (${targets.join(', ')})` : ''}`);
404
495
  // 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 });
496
+ const sched = await runSchedule(pool, spaceSeq, { source, targets, lipsync: !!args.lipsync }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit });
406
497
 
407
498
  await finishPool([...sched.results.values()], perInput, { ...ctx, sched });
408
499
  }
409
500
 
501
+ // Upfront credit-estimate gate for --lipsync: the dub+lip-sync chain is long, and running out of credits
502
+ // half-way strands the user mid-chain — warn before submitting anything when the estimate clearly exceeds
503
+ // the remaining credits. Durations aren't always known locally (no probe available, external URL) — then
504
+ // the gate is skipped and the server's own billing check decides.
505
+ async function creditPreflight(perInput, spaceSeq, targetCount) {
506
+ const plan = await getPlanStatus(spaceSeq);
507
+ const remaining = plan?.remainingQuota;
508
+ if (remaining == null || typeof remaining !== 'number') return;
509
+ // 4K+ surcharge applies to specific plans only (allowlist — an unknown tier gets no surcharge; the server bills authoritatively).
510
+ const uhdBilled = UHD_BILLED_TIERS.includes(String(plan.planTier ?? '').toLowerCase());
511
+ let needed = 0;
512
+ let anyUhd = false;
513
+ for (const pin of perInput) {
514
+ let inputMs = 0;
515
+ for (const c of pin.chunks) {
516
+ let ms = Number.isFinite(c.startMs) && Number.isFinite(c.endMs) ? c.endMs - c.startMs : null;
517
+ if (ms == null && c.source === 'local' && (c.path || pin.inp?.localPath)) {
518
+ ms = (await probe(c.path ?? pin.inp.localPath).catch(() => ({}))).durationMs ?? null;
519
+ }
520
+ if (ms == null || ms <= 0) return; // unknown duration → no estimate possible
521
+ inputMs += ms;
522
+ }
523
+ let mult = 1;
524
+ if (uhdBilled) {
525
+ const src = pin.inp?.localPath ?? pin.chunks.find((c) => c.path)?.path;
526
+ const { width = 0, height = 0 } = src ? await probe(src).catch(() => ({})) : {};
527
+ if (Math.max(width, height) >= 3840) { mult = UHD_CREDIT_MULT; anyUhd = true; } // resolution unknown → assume non-4K (estimate only; the server bills authoritatively)
528
+ }
529
+ needed += Math.ceil(inputMs / 1000) * (CREDIT_RATE_DUB + CREDIT_RATE_LIPSYNC) * targetCount * mult;
530
+ }
531
+ if (!needed || remaining >= needed) return;
532
+ console.log(`[credit-check] Estimated credits for dubbing + lip-sync: ~${needed}${anyUhd ? ` (includes the ×${UHD_CREDIT_MULT} 4K surcharge)` : ''}. Credits left: ${remaining}.`);
533
+ 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):');
534
+ console.log(` → ${withUtm(SUBSCRIPTION_URL)}`);
535
+ throw new ExitCode(3);
536
+ }
537
+
410
538
  // 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.
539
+ // v5 adds submit checkpoints (RUN/LSRUN) and the lip-sync chain (LSWAIT) — nothing already paid for is ever re-submitted.
411
540
  async function runResume(file) {
412
541
  await ensureKey();
413
542
  const m = JSON.parse(readFileSync(file, 'utf8'));
414
- if (m.version !== 4) throw new Error('Unsupported state-file format — run again from the original.');
543
+ if (m.version !== 4 && m.version !== 5) throw new Error('Unsupported state-file format — run again from the original.');
415
544
  const targets = m.targets ?? [m.opts?.target ?? 'en'];
416
545
  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 ?? {} };
546
+ const lipsync = !!m.lipsync;
547
+ const ctx = { spaceSeq: m.spaceSeq, source: m.opts?.source ?? 'auto', targets, out: m.out, multiInput, file, prevDone: m.done ?? {}, lipsync };
418
548
  const outDir = await makeTempDir('dubbing-resume-');
419
549
  const matCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part, shared across languages)
420
550
 
@@ -426,35 +556,45 @@ async function runResume(file) {
426
556
 
427
557
  for (const pin of m.inputs) {
428
558
  const inputStr = pin.ref.source === 'local' ? pin.ref.localPath : pin.ref.sourceUrl;
429
- let prepared;
559
+ let prepared = null;
430
560
  try {
431
561
  prepared = await prepareInput(inputStr); // re-check local / re-download URL
432
562
  } catch (e) {
433
- console.log(`Input not found, skipping: ${pin.ref.originalName ?? inputStr} (${e.message})`);
434
- continue;
563
+ // Server-side work (re-downloads, pending lip-sync) can still proceed without the original;
564
+ // only parts that would need re-cutting/re-uploading are skipped below.
565
+ console.log(`Original not available: ${pin.ref.originalName ?? inputStr ?? 'input'} (${e.message}) — continuing with server-side results only.`);
435
566
  }
436
- const inp = { ...prepared, originalName: prepared.originalName ?? pin.ref.originalName };
437
- const localPath = prepared.localPath ?? prepared.path ?? null;
567
+ const inp = prepared
568
+ ? { ...prepared, originalName: prepared.originalName ?? pin.ref.originalName }
569
+ : { source: pin.ref.source ?? 'local', localPath: pin.ref.localPath ?? null, originalName: pin.ref.originalName ?? null };
570
+ const localPath = prepared ? (prepared.localPath ?? prepared.path ?? null) : null;
438
571
  const materialize = async (c) => {
439
572
  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.');
573
+ if (!localPath) throw new Error('Original not found — cannot resume this part.');
441
574
  if (c.endMs == null) return localPath; // if whole (not split), the original
442
575
  const mk = `${pin.inputId}|${c.index}`;
443
576
  if (!matCache.has(mk)) matCache.set(mk, await recutChunk(localPath, c.startMs, c.endMs));
444
577
  return matCache.get(mk);
445
578
  };
446
579
 
447
- // Completed parts ((inputId|index|target) OK/PASSTHROUGH) use re-download/original; the rest are resubmission targets (excluded from skip).
580
+ // Completed parts use re-download/original; LSWAIT/LSRUN continue the lip-sync chain from the recorded
581
+ // projectSeq (never re-submitting paid work); the rest are resubmission targets (excluded from skip).
448
582
  for (const c of pin.chunks) {
449
583
  for (const target of targets) {
450
584
  const k = `${pin.inputId}|${c.index}|${target}`;
451
585
  const d = m.done?.[k];
452
- if (d?.status === 'OK') {
586
+ const base = { inputId: pin.inputId, index: c.index, target };
587
+ const tag = `[input ${pin.inputId + 1}] part ${c.index + 1}(${target})`;
588
+ if (d?.status === 'OK' || d?.status === 'RUN') {
453
589
  const out = join(outDir, `dub_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
454
590
  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`);
591
+ const dl = await download(d.projectSeq, m.spaceSeq, { kind: c.kind, outPath: out, lipsync: !!d.lipsync });
592
+ downloaded.push({
593
+ ...base, status: 'OK', path: out, projectId: d.projectSeq, name: dl.fileName,
594
+ ...(d.lipsync ? { lipsync: true, dubProjectId: d.dubSeq ?? null } : {}),
595
+ ...(d.lipsyncFailed ? { lipsyncFailed: true } : {}),
596
+ });
597
+ log(`${tag} re-downloaded`);
458
598
  skip.add(k);
459
599
  } catch {
460
600
  // Not downloadable (yet). Only a confirmed server-side failure gets re-dubbed; otherwise keep the
@@ -462,15 +602,61 @@ async function runResume(file) {
462
602
  let failedOnServer = false;
463
603
  try { failedOnServer = (await getStatus(d.projectSeq, m.spaceSeq)).state === 'failed'; } catch { /* unknown → keep waiting */ }
464
604
  if (failedOnServer) {
465
- log(`[input ${pin.inputId + 1}] part ${c.index + 1}(${target}) failed on the server — will re-dub`);
605
+ log(`${tag} failed on the server — will re-dub`);
466
606
  } 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)`);
607
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.projectSeq, reason: 'download_failed', ...(d.lipsync ? { lipsync: true, dubProjectId: d.dubSeq ?? null } : {}) });
608
+ log(`${tag} not ready/download failed — resume again later (no re-dub)`);
609
+ skip.add(k);
610
+ }
611
+ }
612
+ } else if (d?.status === 'LSWAIT') {
613
+ // Dubbing project recorded; lip-sync still owed for this part.
614
+ let s = null;
615
+ try { s = await getStatus(d.projectSeq, m.spaceSeq); } catch { /* unknown → treat as still processing */ }
616
+ if (s?.state === 'complete') {
617
+ pool.push({ inputId: pin.inputId, index: c.index, stage: 'lipsync', parentSeq: d.projectSeq, target, kind: c.kind, startMs: c.startMs, endMs: c.endMs });
618
+ log(`${tag} dubbed — lip-sync will be requested`);
619
+ skip.add(k);
620
+ } else if (s?.state === 'failed') {
621
+ log(`${tag} dubbing failed on the server — will re-dub`);
622
+ } else {
623
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.projectSeq, lipsyncPending: true, reason: 'dub_processing' });
624
+ log(`${tag} dubbing still processing on the server — resume again later`);
625
+ skip.add(k);
626
+ }
627
+ } else if (d?.status === 'LSRUN') {
628
+ // Lip-sync already submitted — never submit again (it would generate and bill again): download, wait, or fall back.
629
+ const out = join(outDir, `lip_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
630
+ try {
631
+ const dl = await download(d.projectSeq, m.spaceSeq, { lipsync: true, outPath: out });
632
+ downloaded.push({ ...base, status: 'OK', path: out, projectId: d.projectSeq, lipsync: true, dubProjectId: d.dubSeq ?? null, name: dl.fileName });
633
+ log(`${tag} lip-sync re-downloaded`);
634
+ skip.add(k);
635
+ } catch {
636
+ let s = null;
637
+ try { s = await getStatus(d.projectSeq, m.spaceSeq); } catch { /* unknown → keep waiting */ }
638
+ if (s?.state === 'failed' && d.dubSeq != null) {
639
+ const out2 = join(outDir, `dub_${pin.inputId}_${String(c.index).padStart(3, '0')}_${target}.mp4`);
640
+ try {
641
+ const dl = await download(d.dubSeq, m.spaceSeq, { kind: c.kind, outPath: out2 });
642
+ downloaded.push({ ...base, status: 'OK', path: out2, projectId: d.dubSeq, lipsyncFailed: true, reason: s.message ?? 'lipsync_failed', name: dl.fileName });
643
+ log(`${tag} lip-sync failed — dubbed video delivered instead`);
644
+ } catch {
645
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.dubSeq, lipsyncFailed: true, reason: 'download_failed' });
646
+ }
647
+ skip.add(k);
648
+ } else {
649
+ downloaded.push({ ...base, status: 'DLFAIL', projectId: d.projectSeq, lipsync: true, dubProjectId: d.dubSeq ?? null, reason: 'download_failed' });
650
+ log(`${tag} lip-sync still processing — resume again later`);
469
651
  skip.add(k);
470
652
  }
471
653
  }
472
654
  } else if (d?.status === 'PASSTHROUGH') {
473
- downloaded.push({ inputId: pin.inputId, index: c.index, target, status: 'PASSTHROUGH', path: await materialize(c) });
655
+ try {
656
+ downloaded.push({ ...base, status: 'PASSTHROUGH', path: await materialize(c) });
657
+ } catch (e) {
658
+ log(`${tag} silent part needs the original video — left out of the merge (${e.message})`);
659
+ }
474
660
  skip.add(k);
475
661
  }
476
662
  }
@@ -478,11 +664,20 @@ async function runResume(file) {
478
664
 
479
665
  // Re-cut only parts that have at least one incomplete language and add them to the pool (languages are filtered out via skip).
480
666
  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 });
667
+ const missing = targets.filter((t) => !skip.has(`${pin.inputId}|${c.index}|${t}`));
668
+ if (!missing.length) continue;
669
+ if (c.parentSeq != null) {
670
+ // lipsync-only plan interrupted before submission — the dubbed project is known, only lip-sync is owed
671
+ 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 });
672
+ } else if (c.source === 'external') {
673
+ pool.push({ inputId: pin.inputId, index: c.index, source: 'external', sourceUrl: c.sourceUrl, kind: c.kind });
674
+ } else {
675
+ try {
676
+ const path = await materialize(c);
677
+ 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 });
678
+ } catch (e) {
679
+ log(`[input ${pin.inputId + 1}] part ${c.index + 1} needs the original video to be re-dubbed — skipped (${e.message})`);
680
+ }
486
681
  }
487
682
  }
488
683
  perInput.push({ inputId: pin.inputId, inp, ref: pin.ref, chunks: pin.chunks });
@@ -490,14 +685,164 @@ async function runResume(file) {
490
685
 
491
686
  if (pool.length) notify('Translating (resume)');
492
687
  const sched = pool.length
493
- ? await runSchedule(pool, m.spaceSeq, { source: m.opts?.source ?? 'auto', targets, done: skip }, { log, onResult: saver.onResult })
688
+ ? await runSchedule(pool, m.spaceSeq, { source: m.opts?.source ?? 'auto', targets, done: skip, lipsync }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit })
494
689
  : { results: new Map(), stopped: false, pendingLeft: [] };
495
690
 
496
691
  await finishPool([...downloaded, ...sched.results.values()], perInput, { ...ctx, sched });
497
692
  }
498
693
 
694
+ // Lip-sync an already-dubbed project set: --lipsync-only "<project-ref JSON | seq[,seq...]>".
695
+ // Nothing is uploaded or re-dubbed — each part's dubbed project gets a lip-sync request (billed by the
696
+ // server as a new generation), then the results are downloaded and merged like a normal run.
697
+ async function runLipsyncOnly(args) {
698
+ await ensureKey();
699
+ const raw = String(args.lipsyncOnly).trim();
700
+ let ref;
701
+ if (raw.startsWith('{')) {
702
+ try { ref = JSON.parse(raw); } catch { throw new UsageError('Invalid --lipsync-only value — pass the [project-ref] JSON or a projectSeq list.'); }
703
+ } else if (/^\d+(\s*,\s*\d+)*$/.test(raw)) {
704
+ ref = { parts: raw.split(',').map((s) => ({ seq: Number(s.trim()) })) };
705
+ } else {
706
+ throw new UsageError('Invalid --lipsync-only value — pass the [project-ref] JSON or a projectSeq list.');
707
+ }
708
+ const parts = Array.isArray(ref.parts) ? ref.parts : [];
709
+ if (!parts.some((p) => p?.seq != null)) throw new UsageError('No dubbed project found in the --lipsync-only reference.');
710
+
711
+ const target = ref.lang ?? 'out';
712
+ const localPath = ref.input && !/^[a-z]+:\/\//i.test(ref.input) ? ref.input : null;
713
+ const inp = { source: 'local', localPath, originalName: localPath ? basename(localPath) : ref.input ?? null };
714
+ const file = resumePath({ out: args.out, inputs: [inp], multiInput: false });
715
+ guardExistingState(file); // an interrupted earlier run owns this state file — resume it instead of re-billing
716
+ const spaceSeq = Number(ref.space) || await ensureSpace(args);
717
+ const ctx = { spaceSeq, source: 'auto', targets: [target], out: args.out, multiInput: false, file, prevDone: {}, lipsync: true };
718
+
719
+ const chunks = parts.map((p, i) => ({
720
+ index: i, source: 'local',
721
+ startMs: p?.ms?.[0] ?? p?.pt?.[0] ?? null, endMs: p?.ms?.[1] ?? p?.pt?.[1] ?? null,
722
+ kind: 'video', ...(p?.seq != null ? { parentSeq: p.seq } : {}),
723
+ }));
724
+ const perInput = [{ inputId: 0, inp, ref: { source: 'local', localPath, originalName: inp.originalName }, chunks }];
725
+ const saver = manifestSaver(ctx, perInput);
726
+
727
+ const downloaded = [];
728
+ const pool = [];
729
+ for (const c of chunks) {
730
+ if (c.parentSeq == null) {
731
+ // silent segment of the original run — rebuild it from the original video for the merge
732
+ if (!localPath) { console.error('This reference contains silent segments — the original video path is required to rebuild them.'); throw new ExitCode(1); }
733
+ const r = { inputId: 0, index: c.index, target, status: 'PASSTHROUGH', path: await recutChunk(localPath, c.startMs, c.endMs) };
734
+ downloaded.push(r);
735
+ saver.onResult(r);
736
+ } else {
737
+ pool.push({ inputId: 0, index: c.index, stage: 'lipsync', parentSeq: c.parentSeq, target, kind: 'video', startMs: c.startMs, endMs: c.endMs });
738
+ }
739
+ }
740
+ saver.writeNow();
741
+ notify('Requesting lip-sync for the existing dubbed project — no re-dubbing, no re-upload.');
742
+ const sched = await runSchedule(pool, spaceSeq, { targets: [target], lipsync: true }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit });
743
+ await finishPool([...downloaded, ...sched.results.values()], perInput, { ...ctx, sched });
744
+ }
745
+
746
+ // ── audio separation (--separate) ─────────────────────────
747
+ // Voice/background separation — no languages involved. Chunks are processed sequentially (one queue slot),
748
+ // long/large sources reuse the same auto-split, and per-track pieces are merged back like dubbed parts.
749
+ // Billing ≈ 0.5 credit per second (server authoritative). Interrupted runs are not resumable yet (re-run re-bills).
750
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
751
+ const SEPARATION_TRACKS = ['voice', 'background', 'sub_background'];
752
+
753
+ async function runSeparation(args) {
754
+ await ensureKey();
755
+ const inputs = await expandInputs(args.inputs, { recursive: args.recursive });
756
+ const spaceSeq = await ensureSpace(args);
757
+ const usedByDir = new Map();
758
+ const lines = [];
759
+ let okCount = 0;
760
+ let failCount = 0;
761
+ for (const inp of inputs) {
762
+ try {
763
+ const line = await separateOne(inp, spaceSeq, { out: args.out, usedByDir });
764
+ lines.push(line);
765
+ if (line.startsWith('Done')) okCount++; else failCount++;
766
+ } catch (e) {
767
+ if (e?.httpStatus === 402) { // out of credits — deliver what finished and surface the top-up path verbatim
768
+ if (lines.length) console.log('\n' + lines.join('\n'));
769
+ const plan = await getPlanStatus(spaceSeq);
770
+ console.log(messages.quotaExceeded({ planTier: plan?.planTier, remainingQuota: plan?.remainingQuota }));
771
+ throw new ExitCode(1);
772
+ }
773
+ if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; }
774
+ failCount++;
775
+ lines.push(`Could not separate: ${labelOf(inp)} — ${friendlyError(e)}`);
776
+ }
777
+ }
778
+ console.log('\n' + lines.join('\n'));
779
+ if (inputs.length > 1) console.log(`\nSummary: ${okCount} done · ${failCount} failed`);
780
+ }
781
+
782
+ async function separateOne(inp, spaceSeq, { out, usedByDir }) {
783
+ const { chunks, notice } = await resolveChunks(inp, spaceSeq, { log, notify });
784
+ if (notice) notify(notice);
785
+ notify(`Separating voice/background — ${labelOf(inp)}`);
786
+
787
+ const byTrack = new Map(SEPARATION_TRACKS.map((t) => [t, []]));
788
+ const tmp = await makeTempDir('dubbing-sep-');
789
+ for (const c of chunks) {
790
+ let { mediaSeq, kind = 'video' } = c;
791
+ if (mediaSeq == null) ({ seq: mediaSeq, kind } = await upload(refOf(inp), spaceSeq)); // external URL uploads here
792
+ const [projectId] = await requestAudioSeparation(spaceSeq, mediaSeq, { title: c.title, kind });
793
+ if (projectId == null) throw new Error('Separation request was not accepted.');
794
+ if (chunks.length > 1) log(`part ${c.index + 1}/${chunks.length}: separation submitted`);
795
+ const st = await waitForProject(projectId, spaceSeq);
796
+ if (st.state !== 'complete') { // a failed part becomes a gap in every track (same as dubbing merge boundaries)
797
+ for (const t of SEPARATION_TRACKS) byTrack.get(t).push({ index: c.index, status: 'HARD_FAIL', reason: st.message ?? st.failureReason ?? 'failed' });
798
+ continue;
799
+ }
800
+ const tracks = await downloadSeparation(projectId, spaceSeq, (label, ext) => join(tmp, `sep_${c.index}_${label}${ext}`));
801
+ for (const t of tracks) byTrack.get(t.label)?.push({ index: c.index, status: 'OK', path: t.path, name: t.fileName });
802
+ }
803
+
804
+ const dir = out ?? inputSaveDir(inp); // --separate treats --out as a folder (three tracks per input)
805
+ mkdirSync(dir, { recursive: true });
806
+ const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
807
+ const saved = [];
808
+ let excluded = false;
809
+ for (const t of SEPARATION_TRACKS) {
810
+ const results = byTrack.get(t);
811
+ if (!results.some((r) => r.status === 'OK')) continue;
812
+ const { outputs, report } = await mergeGroups(results);
813
+ if (report) excluded = true;
814
+ for (let i = 0; i < outputs.length; i++) {
815
+ const o = outputs[i];
816
+ const ext = extname(o.name ?? o.path ?? '') || '.wav';
817
+ const name = reserve(dir, `${stem}.${t}${outputs.length > 1 ? `_${i + 1}` : ''}${ext}`, usedByDir);
818
+ copyFileSync(o.path, join(dir, name));
819
+ saved.push(name);
820
+ }
821
+ }
822
+ if (!saved.length) {
823
+ const why = byTrack.get('voice').find((r) => r.reason)?.reason ?? 'no result';
824
+ return `Could not separate: ${labelOf(inp)} — ${why}`;
825
+ }
826
+ return `Done (separation): ${labelOf(inp)} → ${saved.join(', ')}${excluded ? ' (some parts excluded)' : ''}`;
827
+ }
828
+
829
+ // Poll a project until it settles. Progress changes reset the idle window; a stuck job times out.
830
+ async function waitForProject(projectSeq, spaceSeq) {
831
+ let last = -1;
832
+ let lastChange = Date.now();
833
+ for (;;) {
834
+ let st = null;
835
+ try { st = await getStatus(projectSeq, spaceSeq); } catch { /* transient — keep polling */ }
836
+ if (st?.state === 'complete' || st?.state === 'failed') return st;
837
+ const p = st?.progress ?? -1;
838
+ if (p !== last) { last = p; lastChange = Date.now(); if (p > 0) log(`separating... ${p}%`); }
839
+ if (Date.now() - lastChange > MAX_IDLE_MS) return { state: 'failed', message: 'timed out (no progress)' };
840
+ await sleep(POLL_INTERVAL_MS);
841
+ }
842
+ }
843
+
499
844
  // Pure helper exports for testing (when run directly, only main below executes).
500
- export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes };
845
+ export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes, guardExistingState };
501
846
 
502
847
  async function main() {
503
848
  let exitCode = 0;
@@ -506,7 +851,15 @@ async function main() {
506
851
  const args = parseArgs(process.argv.slice(2));
507
852
  if (args.help) console.log(USAGE);
508
853
  else if (args.resume) await runResume(args.resume);
509
- else if (!args.inputs.length) {
854
+ else if (args.separate) {
855
+ if (args.lipsync || args.lipsyncOnly) throw new UsageError('Use --separate on its own — it cannot be combined with lip-sync options.');
856
+ if (!args.inputs.length) { console.error(USAGE); exitCode = 1; }
857
+ else await runSeparation(args);
858
+ } else if (args.lipsyncOnly) {
859
+ if (args.inputs.length) throw new UsageError('--lipsync-only takes no input files (the project reference identifies them).');
860
+ if (args.lipsync) throw new UsageError('Use either --lipsync (dub + lip-sync) or --lipsync-only, not both.');
861
+ await runLipsyncOnly(args);
862
+ } else if (!args.inputs.length) {
510
863
  console.error(USAGE);
511
864
  exitCode = 1;
512
865
  } else await runPool(args);