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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +3 -2
- package/README.md +7 -1
- package/package.json +2 -2
- package/skills/dubbing/SKILL.md +58 -22
- package/skills/dubbing/lib/api_adapter.mjs +69 -10
- package/skills/dubbing/lib/config.mjs +27 -1
- package/skills/dubbing/lib/ffmpeg.mjs +41 -1
- package/skills/dubbing/lib/merge.mjs +23 -2
- package/skills/dubbing/lib/messages.mjs +4 -3
- package/skills/dubbing/lib/scheduler.mjs +117 -15
- package/skills/dubbing/scripts/dubbing.mjs +398 -45
|
@@ -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
|
|
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}
|
|
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')
|
|
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 (
|
|
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:
|
|
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[
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
434
|
-
|
|
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 =
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
457
|
-
|
|
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(
|
|
605
|
+
log(`${tag} failed on the server — will re-dub`);
|
|
466
606
|
} else {
|
|
467
|
-
downloaded.push({
|
|
468
|
-
log(
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
pool.push({ inputId: pin.inputId, index: c.index,
|
|
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 (
|
|
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);
|