perso-dubbing 0.3.0 → 0.5.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.
@@ -13,11 +13,13 @@ 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
18
  import { download, getStatus, upload, requestAudioSeparation, downloadSeparation } from '../lib/api_adapter.mjs';
19
19
  import { mergeGroups } from '../lib/merge.mjs';
20
20
  import { messages, withUtm, SUBSCRIPTION_URL } from '../lib/messages.mjs';
21
+ import { checkForUpdate } from '../lib/update_check.mjs';
22
+ import { track, initTelemetry } from '../lib/telemetry.mjs';
21
23
  import { cleanupTempDirs, makeTempDir } from '../lib/tmp.mjs';
22
24
  import { probe } from '../lib/ffmpeg.mjs';
23
25
  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';
@@ -38,17 +40,51 @@ function friendlyError(e) {
38
40
  return e?.message ?? 'Unknown error';
39
41
  }
40
42
 
43
+ // Coarse error bucket for telemetry — never the raw message/code.
44
+ function errorClass(e) {
45
+ if (e?.name === 'MissingKeyError' || isAuthError(e)) return 'auth';
46
+ if (e?.name === 'UnsupportedMediaError') return 'unsupported';
47
+ if (e?.name === 'PersoApiError') return 'network';
48
+ return 'unknown';
49
+ }
50
+
51
+ // split-confirm telemetry props: real units + an ESTIMATED part count (the exact split isn't computed until the confirmed re-run).
52
+ function splitConfirmProps(d = {}) {
53
+ const SIZE_CAP = 1.9 * 1024 ** 3; // mirrors split.mjs SIZE_CAP (estimate only)
54
+ if (d.reason === 'length') {
55
+ return {
56
+ reason: 'length',
57
+ duration_sec: Number.isFinite(d.actualMs) ? Math.round(d.actualMs / 1000) : null,
58
+ parts_est: (Number.isFinite(d.actualMs) && d.limitMs) ? Math.ceil(d.actualMs / d.limitMs) : null,
59
+ };
60
+ }
61
+ return {
62
+ reason: 'size',
63
+ size_mb: Number.isFinite(d.actualBytes) ? Math.round(d.actualBytes / 1024 ** 2) : null,
64
+ parts_est: Number.isFinite(d.actualBytes) ? Math.ceil(d.actualBytes / SIZE_CAP) : null,
65
+ };
66
+ }
67
+
68
+ // Sum of known chunk spans across inputs → seconds (null when no boundaries are known, e.g. an unsplit whole).
69
+ function totalDurationSec(perInput) {
70
+ const ms = (perInput || []).reduce((s, p) => s + (p.chunks || []).reduce(
71
+ (a, c) => a + (Number.isFinite(c.startMs) && Number.isFinite(c.endMs) ? c.endMs - c.startMs : 0), 0), 0);
72
+ return ms > 0 ? Math.round(ms / 1000) : null;
73
+ }
74
+
41
75
  // Key gate with self-healing: when no key is registered, hand off to `resolve_key.mjs --watch` in a child
42
76
  // process (creates the key file, opens it in the editor, encrypts on save, deletes the file) and continue
43
77
  // once registered. Runs in a child because Windows DPAPI work (powershell) must not run in this main process.
44
78
  // PERSO_NO_WATCH=1 restores the old fail-fast behavior (headless/CI).
45
79
  async function ensureKey() {
46
- if (resolveKey()) return;
80
+ if (resolveKey()) { track('key_check', { has_key: true }); return; }
81
+ track('key_check', { has_key: false });
47
82
  if (process.env.PERSO_NO_WATCH) {
48
83
  console.error(onboardingHelp());
49
84
  throw new ExitCode(2);
50
85
  }
51
86
  notify('No API key registered — a key file will open; paste just your Perso API key and save it. (Get one: https://developers.perso.ai/api-keys)');
87
+ track('key_onboarding_started');
52
88
  const watcher = fileURLToPath(new URL('./resolve_key.mjs', import.meta.url));
53
89
  const code = await new Promise((res) => {
54
90
  const child = spawn(process.execPath, [watcher, '--watch'], { stdio: 'inherit' });
@@ -60,6 +96,7 @@ async function ensureKey() {
60
96
  console.error(onboardingHelp());
61
97
  throw new ExitCode(2);
62
98
  }
99
+ track('key_registered');
63
100
  notify('API key registered — continuing.');
64
101
  }
65
102
 
@@ -70,9 +107,9 @@ async function ensureKey() {
70
107
  // --space "<space name>".
71
108
  async function ensureSpace(args) {
72
109
  const wanted = args.space != null ? String(args.space).trim() : '';
73
- if (/^\d+$/.test(wanted)) return Number(wanted); // raw seq — power users/scripts; not shown to end users
110
+ if (/^\d+$/.test(wanted)) { track('space_resolved'); return Number(wanted); } // raw seq — power users/scripts; not shown to end users
74
111
  const pinned = Number(process.env.PERSO_SPACE_SEQ);
75
- if (!wanted && pinned) return pinned;
112
+ if (!wanted && pinned) { track('space_resolved'); return pinned; }
76
113
 
77
114
  const spaces = await dubbingSpaces();
78
115
  // Options shown to the user carry "name | (plan) | remaining credits" — never the internal seq.
@@ -83,7 +120,7 @@ async function ensureSpace(args) {
83
120
 
84
121
  if (wanted) {
85
122
  const hits = spaces.filter((s) => s.name.toLowerCase() === wanted.toLowerCase());
86
- if (hits.length === 1) { console.log(`Space: ${brief(hits[0])}`); return hits[0].seq; }
123
+ if (hits.length === 1) { track('space_resolved', { plan_tier: hits[0].tier ?? null }); console.log(`Space: ${brief(hits[0])}`); return hits[0].seq; }
87
124
  if (hits.length > 1) {
88
125
  console.log(`[space-select] Several spaces share the name "${wanted}" — rename one in Perso, or pin PERSO_SPACE_SEQ:`);
89
126
  for (const s of await enrich(hits)) console.log(` PERSO_SPACE_SEQ=${s.seq} → ${label(s)}`);
@@ -94,7 +131,7 @@ async function ensureSpace(args) {
94
131
  throw new ExitCode(3);
95
132
  }
96
133
 
97
- if (spaces.length === 1) return spaces[0].seq;
134
+ if (spaces.length === 1) { track('space_resolved', { plan_tier: spaces[0].tier ?? null }); return spaces[0].seq; }
98
135
  const options = await enrich(spaces);
99
136
  if (process.stdin.isTTY && process.stdout.isTTY) {
100
137
  console.log('Several spaces are available. Which one should this dubbing run in?');
@@ -104,6 +141,7 @@ async function ensureSpace(args) {
104
141
  rl.close();
105
142
  const chosen = options[Number(answer) - 1] ?? options.find((s) => s.name.toLowerCase() === answer.toLowerCase());
106
143
  if (!chosen) { console.error('Invalid selection — run again.'); throw new ExitCode(1); }
144
+ track('space_resolved', { plan_tier: chosen.tier ?? null });
107
145
  console.log(`Space: ${brief(chosen)}`);
108
146
  return chosen.seq;
109
147
  }
@@ -113,7 +151,7 @@ async function ensureSpace(args) {
113
151
  }
114
152
 
115
153
  const USAGE = [
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]',
154
+ 'Usage: node scripts/dubbing.mjs "<file|folder|URL>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder] [--recursive] [--lipsync] [--force] [--no-save]',
117
155
  ' node scripts/dubbing.mjs --lipsync-only "<project-ref JSON | projectSeq[,projectSeq...]>" [--space "space name"] [--out path|folder]',
118
156
  ' node scripts/dubbing.mjs --separate "<file|folder|URL>" ["<another input>" ...] [--space "space name"] [--out folder]',
119
157
  ' node scripts/dubbing.mjs --resume "<state-file>"',
@@ -122,6 +160,7 @@ const USAGE = [
122
160
  ' --lipsync-only lip-sync an already-dubbed project (uses the [project-ref] line printed by a finished run; no re-dub charge)',
123
161
  ' --separate split the source into voice / background / sub-background audio tracks (no dubbing; ~0.5 credit per second)',
124
162
  ' --force skip the upfront credit-estimate gate of --lipsync',
163
+ ' --no-save leave the result on the server without downloading it (single/unsplit input only; not with --lipsync)',
125
164
  ].join('\n');
126
165
 
127
166
  class UsageError extends Error {
@@ -145,6 +184,8 @@ function parseArgs(argv) {
145
184
  else if (t === '--separate') a.separate = true;
146
185
  else if (t === '--lipsync') a.lipsync = true;
147
186
  else if (t === '--force') a.force = true;
187
+ else if (t === '--allow-split') a.allowSplit = true; // user confirmed auto split→dub→merge (set on the re-run after [split-confirm])
188
+ else if (t === '--no-save') a.noSave = true; // server-only: skip downloading the result (single/unsplit input only)
148
189
  else if (t in VALUE_FLAGS) {
149
190
  const v = argv[++i];
150
191
  if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
@@ -166,6 +207,7 @@ async function validateLanguages(targets, source) {
166
207
  const fixed = targets.map((t) => {
167
208
  const hit = canon.get(t.toLowerCase());
168
209
  if (!hit) {
210
+ track('lang_invalid', { field: 'target' });
169
211
  console.error(`Unsupported target language code: "${t}"\nSupported: ${codes.join(', ')}`);
170
212
  throw new ExitCode(1);
171
213
  }
@@ -175,6 +217,7 @@ async function validateLanguages(targets, source) {
175
217
  if (source && source !== 'auto') {
176
218
  const hit = canon.get(source.toLowerCase());
177
219
  if (!hit) {
220
+ track('lang_invalid', { field: 'source' });
178
221
  console.error(`Unsupported source language code: "${source}" (use "auto" to detect)\nSupported: ${codes.join(', ')}`);
179
222
  throw new ExitCode(1);
180
223
  }
@@ -190,6 +233,34 @@ const refOf = (inp) => (inp.source === 'local'
190
233
  // Notice text for skipping an unsupported format (append the reason if present).
191
234
  const skipMsg = (name, e) => `Skipped (unsupported format): ${name}${e?.cause?.message ? ` (${e.cause.message})` : ''}`;
192
235
 
236
+ // [split-confirm] prompt: which limit was exceeded + the quality caveat + how to proceed. Emitted (exit 3) before
237
+ // the first split when --allow-split isn't set; the agent relays it and re-runs with --allow-split on the user's OK.
238
+ function splitConfirmMessage(d, tag, action = 'dub') {
239
+ const min = (ms) => Math.max(1, Math.round(ms / 60000));
240
+ const gb = (b) => (b / 1024 ** 3).toFixed(1);
241
+ const label = tag ? `${tag}: ` : '';
242
+ const noun = action === 'separate' ? 'This file' : 'This video';
243
+ let head;
244
+ if (d.reason === 'length') {
245
+ const lim = d.limitMs ? `${min(d.limitMs)} min` : 'the plan limit';
246
+ head = d.actualMs
247
+ ? `${noun} exceeds the length limit (${lim}; it is ${min(d.actualMs)} min).`
248
+ : `${noun} exceeds the length limit (${lim}).`;
249
+ } else {
250
+ head = d.actualBytes
251
+ ? `${noun} exceeds the 2 GB upload limit (it is ${gb(d.actualBytes)} GB).`
252
+ : `${noun} exceeds the 2 GB upload limit.`;
253
+ }
254
+ const mid = action === 'separate'
255
+ ? 'Separating it needs automatic split → separate → merge, which may come out less polished than splitting it up yourself.'
256
+ : 'Dubbing it needs automatic split → dub → merge, which may come out less polished than splitting it up yourself.';
257
+ return [
258
+ `[split-confirm] ${label}${head}`,
259
+ `[split-confirm] ${mid}`,
260
+ '[split-confirm] Proceed automatically? To confirm, re-run the same command with --allow-split.',
261
+ ].join('\n');
262
+ }
263
+
193
264
  // Save directory (non-volatile): next to the local original; current folder for URL/external/unknown.
194
265
  function inputSaveDir(inp) {
195
266
  if (inp?.source === 'local' && inp.localPath) return dirname(inp.localPath);
@@ -294,7 +365,7 @@ function buildManifest(ctx, perInput, results, prevDone = {}) {
294
365
  }
295
366
  return {
296
367
  version: 5, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
297
- lipsync: !!ctx.lipsync,
368
+ lipsync: !!ctx.lipsync, stop_reason: ctx.stopReason ?? null,
298
369
  inputs: perInput.map((p) => ({
299
370
  inputId: p.inputId, ref: p.ref,
300
371
  chunks: p.chunks.map((c) => ({
@@ -390,6 +461,13 @@ async function finishPool(allResults, perInput, ctx) {
390
461
  failCount++;
391
462
  continue;
392
463
  }
464
+ if (mergeable.length && mergeable.every((r) => r.serverOnly)) {
465
+ // --no-save: the dubbed result was left on the server and never downloaded → report + reference, don't merge/save.
466
+ lines.push(`Kept on server, not saved: ${tlab} → project ${mergeable[0].projectId}`);
467
+ okCount++;
468
+ emitProjectRef(pin, tRes, target, ctx, { lipsync: false });
469
+ continue;
470
+ }
393
471
  const hasLs = mergeable.some((r) => r.lipsync);
394
472
  const lsFailed = mergeable.some((r) => r.lipsyncFailed);
395
473
  const lsPending = mergeable.some((r) => r.lipsyncPending);
@@ -424,12 +502,14 @@ async function finishPool(allResults, perInput, ctx) {
424
502
  const dlPending = allResults.some((r) => r.status === 'DLFAIL');
425
503
  const stopped = !!ctx.sched?.stopped;
426
504
  if (stopped || dlPending) {
505
+ ctx.stopReason = stopped ? 'quota' : 'download'; // recorded in the manifest → resume reports why it stopped
427
506
  if (ctx.multiInput && ctx.out) mkdirSync(ctx.out, { recursive: true });
428
507
  writeFileSync(ctx.file, JSON.stringify(buildManifest(ctx, perInput, allResults, ctx.prevDone ?? {})), 'utf8');
429
508
  if (stopped) {
430
509
  const plan = await getPlanStatus(ctx.spaceSeq);
431
510
  const min = remainingMinutes(ctx.sched.pendingLeft);
432
511
  const lsOwed = allResults.some((r) => r.lipsyncPending);
512
+ track('quota_exceeded', { plan_tier: plan?.planTier ?? null, remaining_min: min });
433
513
  console.log('\n' + messages.quotaExceeded({
434
514
  planTier: plan?.planTier,
435
515
  remainingQuota: plan?.remainingQuota,
@@ -443,10 +523,21 @@ async function finishPool(allResults, perInput, ctx) {
443
523
  } else {
444
524
  try { unlinkSync(ctx.file); } catch { /* done → clean up resume state-file (ignore if absent) */ }
445
525
  }
526
+ if (!ctx.lipsyncOnly) { // lipsync-only runs report via lipsync_only_started, not dubbing_completed
527
+ track('dubbing_completed', {
528
+ input_count: perInput.length, ok_count: okCount, fail_count: failCount,
529
+ had_split: perInput.some((p) => (p.chunks?.length ?? 0) > 1),
530
+ had_lipsync: allResults.some((r) => r.lipsync),
531
+ duration_sec: totalDurationSec(perInput),
532
+ source_lang: ctx.source, target_lang: (ctx.targets || []).join(','),
533
+ is_resume: !!ctx.isResume, recovered: !!ctx.isResume && ctx.resumedFrom === 'quota' && okCount > 0,
534
+ });
535
+ }
446
536
  }
447
537
 
448
538
  // New run: schedule all inputs as a single pool. Per-input split/upload happens once (secures mediaSeq) → reused per language.
449
539
  async function runPool(args) {
540
+ if (args.noSave && args.lipsync) throw new UsageError('--no-save cannot be combined with --lipsync (the lip-synced video must be downloaded).');
450
541
  await ensureKey();
451
542
  const wantedTargets = String(args.target).split(',').map((t) => t.trim()).filter(Boolean); // --target en,ja,ko
452
543
  if (!wantedTargets.length) throw new UsageError('No target language specified (--target en,ja,...)');
@@ -476,14 +567,23 @@ async function runPool(args) {
476
567
  const tag = multiInput ? `[${id + 1}/${inputs.length}] ${labelOf(inp)}` : labelOf(inp);
477
568
  let chunks;
478
569
  try {
479
- ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify }));
570
+ ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
480
571
  } catch (e) {
481
- if (isAuthError(e)) { console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
572
+ if (e?.name === 'SplitConfirmNeeded') {
573
+ track('split_confirm_needed', splitConfirmProps(e.details));
574
+ console.log(splitConfirmMessage(e.details, tag));
575
+ // 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.
576
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
577
+ throw new ExitCode(3);
578
+ }
579
+ if (isAuthError(e)) { track('error', { error_class: 'auth' }); console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
482
580
  if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; } // unsupported → skip
483
581
  console.log(`${tag} — split/upload failed: ${friendlyError(e)}`); continue;
484
582
  }
485
583
  if (chunks.length > 1) notify(`Split complete — ${labelOf(inp)} (${chunks.length} parts)`);
486
- for (const c of chunks) pool.push({ ...c, inputId: id });
584
+ const noDownload = !!args.noSave && chunks.length === 1; // --no-save is single-input only; a split video's merged file needs a local download
585
+ if (args.noSave && chunks.length > 1) notify(`--no-save is not available for split videos (merging needs a local download) — ${labelOf(inp)} will be saved normally.`);
586
+ for (const c of chunks) pool.push({ ...c, inputId: id, noDownload });
487
587
  perInput.push({ inputId: id, inp, ref: refOf(inp), chunks });
488
588
  saver.writeNow(); // the chunk plan (boundaries) survives a crash from this point on
489
589
  }
@@ -491,6 +591,7 @@ async function runPool(args) {
491
591
 
492
592
  if (args.lipsync && !args.force) await creditPreflight(perInput, spaceSeq, targets.length);
493
593
 
594
+ track('dub_submitted', { input_count: perInput.length, parts: pool.length, target_count: targets.length, has_lipsync: !!args.lipsync });
494
595
  notify(`Translating${targets.length > 1 ? ` (${targets.join(', ')})` : ''}`);
495
596
  // 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.
496
597
  const sched = await runSchedule(pool, spaceSeq, { source, targets, lipsync: !!args.lipsync }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit });
@@ -529,6 +630,7 @@ async function creditPreflight(perInput, spaceSeq, targetCount) {
529
630
  needed += Math.ceil(inputMs / 1000) * (CREDIT_RATE_DUB + CREDIT_RATE_LIPSYNC) * targetCount * mult;
530
631
  }
531
632
  if (!needed || remaining >= needed) return;
633
+ track('credit_check_blocked', { credits_needed: needed, credits_remaining: remaining, plan_tier: plan?.planTier ?? null });
532
634
  console.log(`[credit-check] Estimated credits for dubbing + lip-sync: ~${needed}${anyUhd ? ` (includes the ×${UHD_CREDIT_MULT} 4K surcharge)` : ''}. Credits left: ${remaining}.`);
533
635
  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
636
  console.log(` → ${withUtm(SUBSCRIPTION_URL)}`);
@@ -540,11 +642,13 @@ async function creditPreflight(perInput, spaceSeq, targetCount) {
540
642
  async function runResume(file) {
541
643
  await ensureKey();
542
644
  const m = JSON.parse(readFileSync(file, 'utf8'));
645
+ if (m.kind === 'separation') return runResumeSeparation(m, file); // separation has its own (scheduler-less) resume
543
646
  if (m.version !== 4 && m.version !== 5) throw new Error('Unsupported state-file format — run again from the original.');
544
647
  const targets = m.targets ?? [m.opts?.target ?? 'en'];
545
648
  const multiInput = (m.inputs?.length ?? 0) > 1;
546
649
  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 };
650
+ const ctx = { spaceSeq: m.spaceSeq, source: m.opts?.source ?? 'auto', targets, out: m.out, multiInput, file, prevDone: m.done ?? {}, lipsync, isResume: true, resumedFrom: m.stop_reason ?? 'manual' };
651
+ track('resume_started', { mode: 'resume', resumed_from: m.stop_reason ?? 'manual' });
548
652
  const outDir = await makeTempDir('dubbing-resume-');
549
653
  const matCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part, shared across languages)
550
654
 
@@ -707,6 +811,8 @@ async function runLipsyncOnly(args) {
707
811
  }
708
812
  const parts = Array.isArray(ref.parts) ? ref.parts : [];
709
813
  if (!parts.some((p) => p?.seq != null)) throw new UsageError('No dubbed project found in the --lipsync-only reference.');
814
+ const lsMs = parts.reduce((s, p) => { const r = p?.ms ?? p?.pt; return s + (Array.isArray(r) && r.length === 2 ? Math.max(0, r[1] - r[0]) : 0); }, 0);
815
+ track('lipsync_only_started', { input_count: 1, parts: parts.length, duration_sec: lsMs > 0 ? Math.round(lsMs / 1000) : null });
710
816
 
711
817
  const target = ref.lang ?? 'out';
712
818
  const localPath = ref.input && !/^[a-z]+:\/\//i.test(ref.input) ? ref.input : null;
@@ -714,7 +820,7 @@ async function runLipsyncOnly(args) {
714
820
  const file = resumePath({ out: args.out, inputs: [inp], multiInput: false });
715
821
  guardExistingState(file); // an interrupted earlier run owns this state file — resume it instead of re-billing
716
822
  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 };
823
+ const ctx = { spaceSeq, source: 'auto', targets: [target], out: args.out, multiInput: false, file, prevDone: {}, lipsync: true, lipsyncOnly: true };
718
824
 
719
825
  const chunks = parts.map((p, i) => ({
720
826
  index: i, source: 'local',
@@ -750,57 +856,190 @@ async function runLipsyncOnly(args) {
750
856
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
751
857
  const SEPARATION_TRACKS = ['voice', 'background', 'sub_background'];
752
858
 
859
+ // Separation resume state (version 1, kind 'separation'): the chunk plan + a per-chunk projectId checkpoint.
860
+ // Mirrors the dubbing manifest but keyed by inputId|chunkIndex (no target language).
861
+ function buildSepManifest(ctx, perInput, done) {
862
+ return {
863
+ version: 1, kind: 'separation', spaceSeq: ctx.spaceSeq, out: ctx.out ?? null,
864
+ inputs: perInput.map((p) => ({
865
+ inputId: p.inputId, ref: p.ref,
866
+ chunks: p.chunks.map((c) => ({
867
+ index: c.index, source: c.source, sourceUrl: c.sourceUrl ?? null,
868
+ startMs: c.startMs ?? null, endMs: c.endMs ?? null, title: c.title ?? null, kind: c.kind ?? null,
869
+ })),
870
+ })),
871
+ done, // `${inputId}|${index}` → { status:'RUN'|'OK'|'HARD_FAIL', projectId?, reason? }
872
+ };
873
+ }
874
+ function sepSaver(ctx, perInput, prevDone = {}) {
875
+ const done = { ...prevDone };
876
+ const writeNow = () => {
877
+ try {
878
+ mkdirSync(dirname(ctx.file), { recursive: true });
879
+ const tmp = ctx.file + '.tmp';
880
+ writeFileSync(tmp, JSON.stringify(buildSepManifest(ctx, perInput, done)), 'utf8');
881
+ renameSync(tmp, ctx.file);
882
+ } catch { /* best-effort — saving state must never break the run */ }
883
+ };
884
+ return {
885
+ writeNow, done,
886
+ // The paid projectId is persisted the moment it exists → a hard kill can't cause a re-submission on resume.
887
+ onSubmit: (inputId, index, projectId) => { done[`${inputId}|${index}`] = { status: 'RUN', projectId }; writeNow(); },
888
+ onComplete: (inputId, index, projectId) => { done[`${inputId}|${index}`] = { status: 'OK', projectId }; writeNow(); },
889
+ onFail: (inputId, index, reason) => { done[`${inputId}|${index}`] = { status: 'HARD_FAIL', reason }; writeNow(); },
890
+ };
891
+ }
892
+
753
893
  async function runSeparation(args) {
754
894
  await ensureKey();
755
895
  const inputs = await expandInputs(args.inputs, { recursive: args.recursive });
896
+ const multiInput = inputs.length > 1;
897
+ const file = resumePath({ out: args.out, inputs, multiInput });
898
+ guardExistingState(file); // block re-running the original; --resume continues without re-billing submitted parts
756
899
  const spaceSeq = await ensureSpace(args);
900
+ const ctx = { spaceSeq, out: args.out ?? null, file };
901
+ const perInput = [];
902
+ const saver = sepSaver(ctx, perInput);
903
+ // Phase 1 — resolve chunk plans (split-confirm happens here) and persist them before any submission.
904
+ for (let id = 0; id < inputs.length; id++) {
905
+ const inp = inputs[id];
906
+ let chunks;
907
+ try {
908
+ ({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
909
+ } catch (e) {
910
+ if (e?.name === 'SplitConfirmNeeded') {
911
+ track('split_confirm_needed', splitConfirmProps(e.details));
912
+ console.log(splitConfirmMessage(e.details, multiInput ? labelOf(inp) : null, 'separate'));
913
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* nothing billed yet → safe to discard */ }
914
+ throw new ExitCode(3);
915
+ }
916
+ if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; }
917
+ notify(`Could not prepare: ${labelOf(inp)} — ${friendlyError(e)}`); continue;
918
+ }
919
+ if (chunks.length > 1) notify(`Split complete — ${labelOf(inp)} (${chunks.length} parts)`);
920
+ perInput.push({ inputId: id, inp, ref: refOf(inp), chunks });
921
+ saver.writeNow();
922
+ }
923
+ if (!perInput.length) { notify('No inputs to separate.'); return; }
924
+ notify('Separating voice/background');
925
+ // Phase 2 — submit (checkpointed) → wait → download → merge → save.
926
+ const pending = await separationProcess(perInput, spaceSeq, ctx, saver, null, false);
927
+ finishSepState(file, pending);
928
+ }
929
+
930
+ // Drop the state file only when nothing paid is still owed; otherwise keep it and point at --resume (no re-billing).
931
+ function finishSepState(file, pending) {
932
+ if (pending) { notify(`Some parts still need downloading — resume with: node scripts/dubbing.mjs --resume "${file}"`); return; }
933
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
934
+ }
935
+
936
+ // Resume: reload the chunk plan, re-cut/re-upload only the parts never submitted, poll/re-download the submitted ones.
937
+ async function runResumeSeparation(m, file) {
938
+ if (m.version !== 1) throw new Error('Unsupported separation state-file format — run again from the original.');
939
+ const spaceSeq = m.spaceSeq;
940
+ const ctx = { spaceSeq, out: m.out ?? null, file };
941
+ const perInput = [];
942
+ const recutCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part)
943
+ for (const pin of m.inputs) {
944
+ const inputStr = pin.ref.source === 'local' ? pin.ref.localPath : pin.ref.sourceUrl;
945
+ let localPath = null; // null when the original can't be re-prepared → materializeFor's guard fires cleanly (server-side parts still resume)
946
+ try { const prepared = await prepareInput(inputStr); localPath = prepared.localPath ?? prepared.path ?? null; }
947
+ catch (e) { console.log(`Original not available: ${pin.ref.originalName ?? inputStr ?? 'input'} (${e.message}) — server-side results only.`); }
948
+ perInput.push({ inputId: pin.inputId, ref: pin.ref, inp: pin.ref, chunks: pin.chunks, _localPath: localPath });
949
+ }
950
+ const saver = sepSaver(ctx, perInput, m.done ?? {});
951
+ const materializeFor = async (pin, c) => {
952
+ if (c.source === 'external') return null; // can't re-cut → upload the source URL
953
+ if (!pin._localPath) throw new Error('Original not found — cannot resume this part.');
954
+ if (c.endMs == null) return pin._localPath; // whole (unsplit) → the original
955
+ const mk = `${pin.inputId}|${c.index}`;
956
+ if (!recutCache.has(mk)) recutCache.set(mk, await recutChunk(pin._localPath, c.startMs, c.endMs));
957
+ return recutCache.get(mk);
958
+ };
959
+ const pending = await separationProcess(perInput, spaceSeq, ctx, saver, materializeFor, true);
960
+ finishSepState(file, pending);
961
+ }
962
+
963
+ // Per-input: separate every chunk (checkpointing each submission), merge tracks, save. Shared by run + resume.
964
+ // Returns true when some paid part is still owed (undelivered) → the caller must keep the state file for resume.
965
+ async function separationProcess(perInput, spaceSeq, ctx, saver, materializeFor, isResume = false) {
757
966
  const usedByDir = new Map();
758
967
  const lines = [];
759
- let okCount = 0;
760
- let failCount = 0;
761
- for (const inp of inputs) {
968
+ let ok = 0, fail = 0;
969
+ const flags = { pending: false };
970
+ const tmp = await makeTempDir('dubbing-sep-');
971
+ for (const pin of perInput) {
762
972
  try {
763
- const line = await separateOne(inp, spaceSeq, { out: args.out, usedByDir });
973
+ const byTrack = await separateChunks(pin, spaceSeq, tmp, saver, materializeFor, flags);
974
+ const line = await saveSeparationTracks(pin, byTrack, { out: ctx.out, usedByDir });
764
975
  lines.push(line);
765
- if (line.startsWith('Done')) okCount++; else failCount++;
976
+ if (line.startsWith('Done')) ok++; else fail++;
766
977
  } catch (e) {
767
- if (e?.httpStatus === 402) { // out of credits — deliver what finished and surface the top-up path verbatim
978
+ if (e?.httpStatus === 402) { // out of credits — deliver what finished + top-up/resume path, then stop
768
979
  if (lines.length) console.log('\n' + lines.join('\n'));
769
980
  const plan = await getPlanStatus(spaceSeq);
770
- console.log(messages.quotaExceeded({ planTier: plan?.planTier, remainingQuota: plan?.remainingQuota }));
981
+ console.log(messages.quotaExceeded({ planTier: plan?.planTier, remainingQuota: plan?.remainingQuota, resumeHint: `node scripts/dubbing.mjs --resume "${ctx.file}"` }));
771
982
  throw new ExitCode(1);
772
983
  }
773
- if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; }
774
- failCount++;
775
- lines.push(`Could not separate: ${labelOf(inp)} — ${friendlyError(e)}`);
984
+ if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(pin.inp ?? pin.ref), e)); continue; }
985
+ flags.pending = true; // errored after some chunks may have been billed → keep state so resume re-downloads them
986
+ fail++;
987
+ lines.push(`Could not separate: ${labelOf(pin.inp ?? pin.ref)} — ${friendlyError(e)}`);
776
988
  }
777
989
  }
778
990
  console.log('\n' + lines.join('\n'));
779
- if (inputs.length > 1) console.log(`\nSummary: ${okCount} done · ${failCount} failed`);
991
+ if (perInput.length > 1) console.log(`\nSummary: ${ok} done · ${fail} failed`);
992
+ track('separation_completed', { input_count: perInput.length, ok_count: ok, fail_count: fail, duration_sec: totalDurationSec(perInput), is_resume: isResume });
993
+ return flags.pending;
780
994
  }
781
995
 
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
-
996
+ // Separate one input's chunks. A chunk with a recorded projectId is polled/re-downloaded (no re-submit); the rest are
997
+ // (re-cut/uploaded and) submitted, checkpointing the projectId the instant it exists.
998
+ async function separateChunks(pin, spaceSeq, tmp, saver, materializeFor, flags) {
787
999
  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;
1000
+ const gap = (index, reason) => { for (const t of SEPARATION_TRACKS) byTrack.get(t).push({ index, status: 'HARD_FAIL', reason }); };
1001
+ for (const c of pin.chunks) {
1002
+ const prev = saver.done[`${pin.inputId}|${c.index}`];
1003
+ if (prev?.status === 'HARD_FAIL') { gap(c.index, prev.reason ?? 'failed'); continue; }
1004
+ let projectId = prev?.projectId ?? null;
1005
+ try {
1006
+ if (projectId == null) { // never submitted → (re-cut/upload and) submit
1007
+ let mediaSeq = c.mediaSeq ?? null, kind = c.kind ?? 'video';
1008
+ if (mediaSeq == null) {
1009
+ const cut = materializeFor ? await materializeFor(pin, c) : null;
1010
+ const ref = cut ? { source: 'local', localPath: cut, originalName: basename(cut) } // basename keeps the extension (c.title has none)
1011
+ : (c.source === 'external' ? { source: 'external', sourceUrl: c.sourceUrl } : refOf(pin.inp ?? pin.ref));
1012
+ ({ seq: mediaSeq, kind } = await upload(ref, spaceSeq));
1013
+ }
1014
+ ([projectId] = await requestAudioSeparation(spaceSeq, mediaSeq, { title: c.title, kind }));
1015
+ if (projectId == null) throw new Error('Separation request was not accepted.');
1016
+ saver.onSubmit(pin.inputId, c.index, projectId); // checkpoint the billed unit before any wait
1017
+ if (pin.chunks.length > 1) log(`part ${c.index + 1}/${pin.chunks.length}: separation submitted`);
1018
+ }
1019
+ if (prev?.status !== 'OK') { // RUN (resume) or freshly submitted → wait for completion
1020
+ const st = await waitForProject(projectId, spaceSeq);
1021
+ if (st.state !== 'complete') {
1022
+ 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
1023
+ saver.onFail(pin.inputId, c.index, st.message ?? st.failureReason ?? 'failed'); // genuine server failure → terminal
1024
+ gap(c.index, st.message ?? st.failureReason ?? 'failed');
1025
+ continue;
1026
+ }
1027
+ saver.onComplete(pin.inputId, c.index, projectId);
1028
+ }
1029
+ const tracks = await downloadSeparation(projectId, spaceSeq, (label, ext) => join(tmp, `sep_${pin.inputId}_${c.index}_${label}${ext}`));
1030
+ for (const t of tracks) byTrack.get(t.label)?.push({ index: c.index, status: 'OK', path: t.path, name: t.fileName });
1031
+ } catch (e) {
1032
+ if (e?.httpStatus === 402) throw e; // credit-out → let separationProcess deliver finished parts + surface the resume path
1033
+ // One part failed to prepare/upload/submit/download — gap it so finished sibling parts still merge & save; keep state so resume retries.
1034
+ flags.pending = true;
1035
+ gap(c.index, friendlyError(e));
799
1036
  }
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
1037
  }
1038
+ return byTrack;
1039
+ }
803
1040
 
1041
+ async function saveSeparationTracks(pin, byTrack, { out, usedByDir }) {
1042
+ const inp = pin.inp ?? pin.ref;
804
1043
  const dir = out ?? inputSaveDir(inp); // --separate treats --out as a folder (three tracks per input)
805
1044
  mkdirSync(dir, { recursive: true });
806
1045
  const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
@@ -836,19 +1075,31 @@ async function waitForProject(projectSeq, spaceSeq) {
836
1075
  if (st?.state === 'complete' || st?.state === 'failed') return st;
837
1076
  const p = st?.progress ?? -1;
838
1077
  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)' };
1078
+ if (Date.now() - lastChange > MAX_IDLE_MS) return { state: 'failed', timedOut: true, message: 'timed out (no progress)' };
840
1079
  await sleep(POLL_INTERVAL_MS);
841
1080
  }
842
1081
  }
843
1082
 
844
1083
  // Pure helper exports for testing (when run directly, only main below executes).
845
- export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes, guardExistingState };
1084
+ export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes, guardExistingState, splitConfirmMessage, buildSepManifest, sepSaver };
846
1085
 
847
1086
  async function main() {
848
1087
  let exitCode = 0;
1088
+ let updateNotice = null; // daily version check, kicked off in the background and printed after the work finishes
849
1089
  try {
850
1090
  preloadKeyEnv(); // pre-inject the key into env before async (at a clean point) → avoid a synchronous powershell call/crash in the main process
851
1091
  const args = parseArgs(process.argv.slice(2));
1092
+ if (!args.help) {
1093
+ updateNotice = checkForUpdate().catch(() => null); // non-blocking; never fails the run
1094
+ initTelemetry(); // emits first_run once per install
1095
+ track('run_started', {
1096
+ mode: args.resume ? 'resume' : args.separate ? 'separate' : args.lipsyncOnly ? 'lipsync-only' : args.lipsync ? 'lipsync' : 'dub',
1097
+ input_count: args.inputs.length || null,
1098
+ target_count: String(args.target).split(',').map((s) => s.trim()).filter(Boolean).length || null,
1099
+ has_lipsync: !!(args.lipsync || args.lipsyncOnly),
1100
+ source_lang: args.source,
1101
+ });
1102
+ }
852
1103
  if (args.help) console.log(USAGE);
853
1104
  else if (args.resume) await runResume(args.resume);
854
1105
  else if (args.separate) {
@@ -866,10 +1117,12 @@ async function main() {
866
1117
  } catch (e) {
867
1118
  if (e?.name === 'ExitCode') exitCode = e.code; // message already printed at the throw site
868
1119
  else if (e?.name === 'UsageError') { console.error(`${e.message}\n${USAGE}`); exitCode = 1; }
869
- else { console.error(friendlyError(e)); exitCode = 1; }
1120
+ else { track('error', { error_class: errorClass(e) }); console.error(friendlyError(e)); exitCode = 1; }
870
1121
  } finally {
871
1122
  await cleanupTempDirs(); // bulk-clean the cut/schedule/merge/download temp folders
872
1123
  }
1124
+ // After the work is done (never mid-job), surface a version-update notice if one is available.
1125
+ if (updateNotice) { try { const n = await updateNotice; if (n) console.log('\n' + n); } catch { /* ignore */ } }
873
1126
  // Natural exit (loop drain) — process.exit() while fetch sockets are closing hits a Windows libuv assert
874
1127
  // (async.c) that corrupts the exit code. The unref'd timer is a hard-exit fallback if a handle ever hangs.
875
1128
  process.exitCode = exitCode;
@@ -18,7 +18,7 @@ function parseArg(arg) {
18
18
  try {
19
19
  const prepared = parseArg(process.argv[2]);
20
20
  const spaceSeq = Number(process.env.PERSO_SPACE_SEQ) || (await resolveSpace());
21
- const result = await resolveChunks(prepared, spaceSeq);
21
+ const result = await resolveChunks(prepared, spaceSeq, { allowSplit: true }); // debug tool: split without the pre-confirm gate
22
22
  console.log(JSON.stringify(result));
23
23
  } catch (e) {
24
24
  console.error(e.message);