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
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
// - PASSTHROUGH : silent (split chunk) → original chunk path as-is (merge included)
|
|
5
5
|
// - DLFAIL : generated (has projectSeq) · only download failed → re-download on resume (no regeneration)
|
|
6
6
|
// - HARD_FAIL : excluded from merge (group boundary)
|
|
7
|
+
// With opts.lipsync, a completed dubbing task is re-queued as a stage:'lipsync' task (parentSeq = dubbing
|
|
8
|
+
// projectSeq) in the same pool: lip-sync jobs share the queue slots, are polled less often, and are never
|
|
9
|
+
// re-submitted on failure (a repeat request bills again) — a failed lip-sync falls back to the dubbed video.
|
|
7
10
|
import { basename, join } from 'node:path';
|
|
8
11
|
import { makeTempDir } from './tmp.mjs';
|
|
9
|
-
import { upload, requestTranslation, getStatus, download, getQueueStatus, cancel } from './api_adapter.mjs';
|
|
12
|
+
import { upload, requestTranslation, requestLipSync, getStatus, download, getQueueStatus, cancel } from './api_adapter.mjs';
|
|
10
13
|
import { PersoApiError } from './http_client.mjs';
|
|
11
14
|
import {
|
|
12
15
|
BACKOFF_BASE_MS, BACKOFF_MAX_MS, POLL_INTERVAL_MS,
|
|
13
16
|
MAX_IDLE_MS, MAX_RETRY, QUEUE_WAIT_MS,
|
|
17
|
+
LIPSYNC_POLL_INTERVAL_MS, LIPSYNC_IDLE_PER_DURATION, LIPSYNC_IDLE_MS,
|
|
14
18
|
} from './config.mjs';
|
|
15
19
|
|
|
16
20
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -23,7 +27,9 @@ const QUEUE_FULL_CODES = new Set(['VT4292', 'VT5034']);
|
|
|
23
27
|
// An unsplit input has a single piece. The same (inputId,index) shares mediaSeq across languages (uploaded once).
|
|
24
28
|
export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
25
29
|
const log = hooks.log ?? (() => {});
|
|
30
|
+
const notify = hooks.notify ?? (() => {});
|
|
26
31
|
const onResult = hooks.onResult ?? (() => {}); // fired per confirmed result — lets the caller persist resume state incrementally
|
|
32
|
+
const onSubmit = hooks.onSubmit ?? (() => {}); // fired right after a submission — persists the projectSeq before any result exists
|
|
27
33
|
const outDir = await makeTempDir('dubbing-out-');
|
|
28
34
|
|
|
29
35
|
// Unit of work = (input × chunk × language). Expand every chunk of every input per language to fill one queue.
|
|
@@ -33,9 +39,15 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
33
39
|
const mediaKey = (t) => `${t.inputId ?? 0}|${t.index}`;
|
|
34
40
|
|
|
35
41
|
const pending = [];
|
|
36
|
-
for (const c of chunks)
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
for (const c of chunks) {
|
|
43
|
+
if (c.stage === 'lipsync') { // pre-targeted (resume/lipsync-only): explicitly requested by the caller, carries its own language
|
|
44
|
+
pending.push({ ...c, inputId: c.inputId ?? 0, retries: 0 });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
for (const target of targets) {
|
|
48
|
+
const t = { ...c, inputId: c.inputId ?? 0, path: c.path ?? c.localPath, target, retries: 0 };
|
|
49
|
+
if (!skip.has(taskKey(t))) pending.push(t);
|
|
50
|
+
}
|
|
39
51
|
}
|
|
40
52
|
const submitted = new Map(); // projectId → task (input × chunk × language)
|
|
41
53
|
const results = new Map(); // taskKey → result
|
|
@@ -45,13 +57,29 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
45
57
|
let engineError = null; // unrecoverable engine error message (for reporting upward)
|
|
46
58
|
let backoff = BACKOFF_BASE_MS;
|
|
47
59
|
let lastProgressAt = Date.now(); // time of last progress. Guards on 'no progress' rather than absolute elapsed time.
|
|
60
|
+
let lipsyncAnnounced = false;
|
|
48
61
|
|
|
49
|
-
const
|
|
62
|
+
const taskMs = (t) => (Number.isFinite(t?.startMs) && Number.isFinite(t?.endMs) ? t.endMs - t.startMs : null);
|
|
63
|
+
// No-progress allowance: while a lip-sync job is in flight, scale it to the (known) video duration.
|
|
64
|
+
const idleLimit = () => {
|
|
65
|
+
let limit = MAX_IDLE_MS;
|
|
66
|
+
for (const t of submitted.values()) {
|
|
67
|
+
if (t.stage !== 'lipsync') continue;
|
|
68
|
+
const ms = taskMs(t);
|
|
69
|
+
limit = Math.max(limit, ms ? ms * LIPSYNC_IDLE_PER_DURATION : LIPSYNC_IDLE_MS);
|
|
70
|
+
}
|
|
71
|
+
return limit;
|
|
72
|
+
};
|
|
73
|
+
const timeUp = () => Date.now() - lastProgressAt >= idleLimit();
|
|
50
74
|
const setResult = (t, r) => {
|
|
51
75
|
const rec = { inputId: t.inputId ?? 0, index: t.index, target: t.target, ...r };
|
|
52
76
|
results.set(taskKey(t), rec);
|
|
53
77
|
try { onResult(rec); } catch { /* state saving must never break scheduling */ }
|
|
54
78
|
};
|
|
79
|
+
const checkpoint = (t, pid) => {
|
|
80
|
+
try { onSubmit({ inputId: t.inputId ?? 0, index: t.index, target: t.target, stage: t.stage ?? 'dub', projectId: pid, parentSeq: t.parentSeq ?? null, lipsync: !!opts.lipsync }); }
|
|
81
|
+
catch { /* state saving must never break scheduling */ }
|
|
82
|
+
};
|
|
55
83
|
|
|
56
84
|
// On stop_all, stop new submissions and only finalize in-flight (submitted) ones → exit when empty (pending is preserved).
|
|
57
85
|
while ((submitted.size || (!stopAll && pending.length)) && !timeUp()) {
|
|
@@ -73,18 +101,29 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
73
101
|
continue;
|
|
74
102
|
}
|
|
75
103
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
chunk.
|
|
104
|
+
let pid;
|
|
105
|
+
if (chunk.stage === 'lipsync') {
|
|
106
|
+
[pid] = await requestLipSync(chunk.parentSeq, spaceSeq, { speed: opts.speed });
|
|
107
|
+
} else {
|
|
108
|
+
if (chunk.mediaSeq === undefined) {
|
|
109
|
+
let m = mediaByKey.get(mediaKey(chunk));
|
|
110
|
+
if (!m) { m = await upload(toPrepared(chunk), spaceSeq); mediaByKey.set(mediaKey(chunk), m); } // once per chunk (shared across languages)
|
|
111
|
+
chunk.mediaSeq = m.seq;
|
|
112
|
+
chunk.kind = chunk.kind ?? m.kind;
|
|
113
|
+
}
|
|
114
|
+
[pid] = await requestTranslation(spaceSeq, chunk.mediaSeq, { ...opts, target: chunk.target, title: chunk.title ?? opts.title, kind: chunk.kind });
|
|
81
115
|
}
|
|
82
|
-
const [pid] = await requestTranslation(spaceSeq, chunk.mediaSeq, { ...opts, target: chunk.target, title: chunk.title ?? opts.title, kind: chunk.kind });
|
|
83
116
|
if (pid == null) throw new Error('no projectId');
|
|
84
117
|
submitted.set(pid, chunk);
|
|
118
|
+
checkpoint(chunk, pid); // persisted before any result — a killed process must not lose a paid submission
|
|
85
119
|
progressed = true;
|
|
86
120
|
slots -= 1;
|
|
87
|
-
|
|
121
|
+
if (chunk.stage === 'lipsync') {
|
|
122
|
+
if (!lipsyncAnnounced) { lipsyncAnnounced = true; notify('Lip-sync generation started — this takes considerably longer than dubbing; progress updates will follow.'); }
|
|
123
|
+
log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1}(${chunk.target}) lip-sync submitted`);
|
|
124
|
+
} else {
|
|
125
|
+
log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1}(${chunk.target}) submitted`);
|
|
126
|
+
}
|
|
88
127
|
} catch (e) {
|
|
89
128
|
const code = e instanceof PersoApiError ? e.code : null;
|
|
90
129
|
if (isCreditError(e)) {
|
|
@@ -115,8 +154,10 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
115
154
|
await sleep(POLL_INTERVAL_MS);
|
|
116
155
|
for (const [pid, chunk] of [...submitted]) {
|
|
117
156
|
if (!submitted.has(pid)) continue; // already handled this round (e.g. via sibling cancel) → prevent double processing
|
|
157
|
+
if (chunk.stage === 'lipsync' && Date.now() < (chunk._nextPollAt ?? 0)) continue; // long-running → poll on a relaxed cadence
|
|
118
158
|
let st = null;
|
|
119
159
|
try { st = await getStatus(pid, spaceSeq); } catch { /* transient error → next round */ }
|
|
160
|
+
if (chunk.stage === 'lipsync') chunk._nextPollAt = Date.now() + LIPSYNC_POLL_INTERVAL_MS;
|
|
120
161
|
if (!st || st.state === 'processing') {
|
|
121
162
|
// if the server is raising the progress %, treat it as 'progress' and reset the no-progress timer (don't kill slow long videos).
|
|
122
163
|
if (st && typeof st.progress === 'number' && st.progress > (chunk._progress ?? -1)) {
|
|
@@ -129,7 +170,40 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
129
170
|
submitted.delete(pid);
|
|
130
171
|
progressed = true;
|
|
131
172
|
const tag = `[Input ${chunk.inputId + 1}] segment ${chunk.index + 1}(${chunk.target})`;
|
|
173
|
+
|
|
174
|
+
if (chunk.stage === 'lipsync') {
|
|
175
|
+
// Lip-sync outcome. Never re-submit (a repeat request generates & bills again) — on failure fall back to the dubbed video.
|
|
176
|
+
if (st.state === 'complete') {
|
|
177
|
+
const out = join(outDir, `lip_${chunk.inputId}_${String(chunk.index).padStart(3, '0')}_${chunk.target}.mp4`);
|
|
178
|
+
try {
|
|
179
|
+
const dl = await download(pid, spaceSeq, { lipsync: true, outPath: out });
|
|
180
|
+
setResult(chunk, { status: 'OK', projectId: pid, dubProjectId: chunk.parentSeq, lipsync: true, path: out, name: dl.fileName });
|
|
181
|
+
log(`${tag} lip-sync done`);
|
|
182
|
+
} catch {
|
|
183
|
+
setResult(chunk, { status: 'DLFAIL', projectId: pid, dubProjectId: chunk.parentSeq, lipsync: true, reason: 'download_failed' });
|
|
184
|
+
log(`${tag} lip-sync generated (download failed → re-download on resume)`);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
log(`${tag} lip-sync failed${st.message ? ` (${st.message})` : ''} — falling back to the dubbed video`);
|
|
188
|
+
const out = join(outDir, `dub_${chunk.inputId}_${String(chunk.index).padStart(3, '0')}_${chunk.target}.mp4`);
|
|
189
|
+
try {
|
|
190
|
+
const dl = await download(chunk.parentSeq, spaceSeq, { kind: chunk.kind, outPath: out });
|
|
191
|
+
setResult(chunk, { status: 'OK', projectId: chunk.parentSeq, lipsyncFailed: true, reason: st.message ?? 'lipsync_failed', path: out, name: dl.fileName });
|
|
192
|
+
} catch {
|
|
193
|
+
setResult(chunk, { status: 'DLFAIL', projectId: chunk.parentSeq, lipsyncFailed: true, reason: 'download_failed' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
132
199
|
if (st.state === 'complete') {
|
|
200
|
+
// Chain: with opts.lipsync, a dubbed video is not the final deliverable — re-queue as a lip-sync task.
|
|
201
|
+
if (opts.lipsync && chunk.kind !== 'audio') {
|
|
202
|
+
pending.push({ ...chunk, stage: 'lipsync', parentSeq: pid, retries: 0, _progress: undefined });
|
|
203
|
+
log(`${tag} dubbed — queueing lip-sync`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (opts.lipsync && chunk.kind === 'audio') log(`${tag} is audio — lip-sync skipped, delivering the dubbed audio`);
|
|
133
207
|
const out = join(outDir, `dub_${chunk.inputId}_${String(chunk.index).padStart(3, '0')}_${chunk.target}.mp4`);
|
|
134
208
|
try {
|
|
135
209
|
const dl = await download(pid, spaceSeq, { kind: chunk.kind, outPath: out });
|
|
@@ -182,6 +256,24 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
182
256
|
if (progressed) { backoff = BACKOFF_BASE_MS; lastProgressAt = Date.now(); } // on progress, reset backoff/no-progress timer
|
|
183
257
|
}
|
|
184
258
|
|
|
259
|
+
// Credit stop with lip-sync still owed: the dubbing is already paid for — save the dubbed videos now
|
|
260
|
+
// and leave only the lip-sync step to resume (no re-dub charge).
|
|
261
|
+
if (stopAll && stopReason === 'credit') {
|
|
262
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
263
|
+
const c = pending[i];
|
|
264
|
+
if (c.stage !== 'lipsync') continue;
|
|
265
|
+
pending.splice(i, 1);
|
|
266
|
+
const out = join(outDir, `dub_${c.inputId}_${String(c.index).padStart(3, '0')}_${c.target}.mp4`);
|
|
267
|
+
try {
|
|
268
|
+
const dl = await download(c.parentSeq, spaceSeq, { kind: c.kind, outPath: out });
|
|
269
|
+
setResult(c, { status: 'OK', projectId: c.parentSeq, lipsyncPending: true, path: out, name: dl.fileName });
|
|
270
|
+
log(`[Input ${c.inputId + 1}] segment ${c.index + 1}(${c.target}) dubbed video saved — lip-sync will run on resume`);
|
|
271
|
+
} catch {
|
|
272
|
+
setResult(c, { status: 'DLFAIL', projectId: c.parentSeq, lipsyncPending: true, reason: 'download_failed' });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
185
277
|
if (timeUp()) failRemaining('elapsed_exceeded');
|
|
186
278
|
|
|
187
279
|
return {
|
|
@@ -190,22 +282,31 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
190
282
|
stopped: stopAll,
|
|
191
283
|
stopReason,
|
|
192
284
|
engineError,
|
|
193
|
-
pendingLeft: pending.map(({ retries, mediaSeq, _progress, ...c }) => c), // preserved on stop_all (for resume). mediaSeq removed → re-upload.
|
|
285
|
+
pendingLeft: pending.map(({ retries, mediaSeq, _progress, _nextPollAt, ...c }) => c), // preserved on stop_all (for resume). mediaSeq removed → re-upload.
|
|
194
286
|
};
|
|
195
287
|
|
|
196
288
|
function failRemaining(reason) {
|
|
197
|
-
for (const c of pending) if (!results.has(taskKey(c)))
|
|
289
|
+
for (const c of pending) if (!results.has(taskKey(c))) {
|
|
290
|
+
// A pending lip-sync task still has a completed dubbing behind it — keep that seq so resume only re-runs lip-sync.
|
|
291
|
+
if (c.stage === 'lipsync') setResult(c, { status: 'DLFAIL', projectId: c.parentSeq, lipsyncPending: true, reason });
|
|
292
|
+
else setResult(c, { status: 'HARD_FAIL', reason });
|
|
293
|
+
}
|
|
198
294
|
// In-flight projects may still finish server-side (credits already spent) → keep the projectSeq as DLFAIL
|
|
199
295
|
// so resume re-downloads the result instead of re-dubbing (no double billing). A confirmed server-side
|
|
200
296
|
// failure is converted back to a re-dub by the resume flow.
|
|
201
|
-
for (const [pid, c] of submitted) if (!results.has(taskKey(c)))
|
|
297
|
+
for (const [pid, c] of submitted) if (!results.has(taskKey(c))) {
|
|
298
|
+
if (c.stage === 'lipsync') setResult(c, { status: 'DLFAIL', projectId: pid, dubProjectId: c.parentSeq, lipsync: true, reason });
|
|
299
|
+
else setResult(c, { status: 'DLFAIL', projectId: pid, reason });
|
|
300
|
+
}
|
|
202
301
|
submitted.clear();
|
|
203
302
|
pending.length = 0;
|
|
204
303
|
}
|
|
205
304
|
|
|
206
305
|
// a chunk with an engine error yields the same result in any language → cancel in-flight siblings (same input·chunk, other languages) + drop pending ones.
|
|
306
|
+
// Lip-sync siblings are left alone: their dubbing already succeeded, so the media itself is fine.
|
|
207
307
|
async function cancelSiblings(inputId, index, exceptPid) {
|
|
208
308
|
for (const [pid2, t] of [...submitted]) {
|
|
309
|
+
if (t.stage === 'lipsync') continue;
|
|
209
310
|
if (t.inputId === inputId && t.index === index && pid2 !== exceptPid) {
|
|
210
311
|
await cancel(pid2, spaceSeq);
|
|
211
312
|
submitted.delete(pid2);
|
|
@@ -214,6 +315,7 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
|
|
|
214
315
|
}
|
|
215
316
|
}
|
|
216
317
|
for (let i = pending.length - 1; i >= 0; i--) {
|
|
318
|
+
if (pending[i].stage === 'lipsync') continue;
|
|
217
319
|
if (pending[i].inputId === inputId && pending[i].index === index) {
|
|
218
320
|
setResult(pending[i], { status: 'HARD_FAIL', reason: 'engine_error' });
|
|
219
321
|
pending.splice(i, 1);
|