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