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.
@@ -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) for (const target of targets) {
37
- const t = { ...c, inputId: c.inputId ?? 0, path: c.path ?? c.localPath, target, retries: 0 };
38
- if (!skip.has(taskKey(t))) pending.push(t);
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 timeUp = () => Date.now() - lastProgressAt >= MAX_IDLE_MS;
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
- if (chunk.mediaSeq === undefined) {
77
- let m = mediaByKey.get(mediaKey(chunk));
78
- if (!m) { m = await upload(toPrepared(chunk), spaceSeq); mediaByKey.set(mediaKey(chunk), m); } // once per chunk (shared across languages)
79
- chunk.mediaSeq = m.seq;
80
- chunk.kind = chunk.kind ?? m.kind;
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
- log(`[Input ${chunk.inputId + 1}] segment ${chunk.index + 1}(${chunk.target}) submitted`);
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))) setResult(c, { status: 'HARD_FAIL', reason });
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))) setResult(c, { status: 'DLFAIL', projectId: pid, reason });
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);