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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +65 -21
- package/package.json +1 -1
- package/skills/dubbing/SKILL.md +40 -3
- package/skills/dubbing/lib/billing.mjs +159 -0
- package/skills/dubbing/lib/messages.mjs +8 -14
- package/skills/dubbing/lib/scheduler.mjs +16 -10
- package/skills/dubbing/lib/split.mjs +28 -7
- package/skills/dubbing/lib/telemetry.mjs +75 -0
- package/skills/dubbing/lib/update_check.mjs +83 -0
- package/skills/dubbing/scripts/billing.mjs +223 -0
- package/skills/dubbing/scripts/dubbing.mjs +298 -45
- package/skills/dubbing/scripts/probe_split.mjs +1 -1
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
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'))
|
|
976
|
+
if (line.startsWith('Done')) ok++; else fail++;
|
|
766
977
|
} catch (e) {
|
|
767
|
-
if (e?.httpStatus === 402) { // out of credits — deliver what finished
|
|
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
|
-
|
|
775
|
-
|
|
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 (
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
|
789
|
-
for (const c of chunks) {
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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);
|