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.
@@ -3,7 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { platform } from 'node:os';
5
5
  import { readdir } from 'node:fs/promises';
6
- import { join } from 'node:path';
6
+ import { join, extname } from 'node:path';
7
7
  import { makeTempDir } from './tmp.mjs';
8
8
 
9
9
  const exec = promisify(execFile);
@@ -114,6 +114,46 @@ export function encoderVideoArgs(enc) {
114
114
  }
115
115
  }
116
116
 
117
+ // ── passthrough normalization (mixed-group concat safety) ─────
118
+ /** Codec parameters of the first video/audio stream — the reference for normalizing passthrough pieces. */
119
+ export async function probeStreams(path) {
120
+ const { stdout } = await exec('ffprobe', ['-v', 'error',
121
+ '-show_entries', 'stream=codec_type,width,height,pix_fmt,avg_frame_rate,sample_rate,channels',
122
+ '-of', 'json', path], { maxBuffer: 1 << 20 });
123
+ const streams = JSON.parse(stdout)?.streams ?? [];
124
+ const v = streams.find((s) => s.codec_type === 'video');
125
+ const a = streams.find((s) => s.codec_type === 'audio');
126
+ // keep fps as the exact ffprobe fraction (e.g. 30000/1001) — a float would drift on NTSC rates
127
+ const fps = /^[1-9]\d*\/[1-9]\d*$/.test(v?.avg_frame_rate ?? '') ? v.avg_frame_rate : null;
128
+ return {
129
+ video: v ? { width: v.width, height: v.height, pixFmt: v.pix_fmt ?? null, fps } : null,
130
+ audio: a ? { sampleRate: Number(a.sample_rate) || null, channels: a.channels ?? null } : null,
131
+ };
132
+ }
133
+
134
+ /** Re-encode one piece to the reference parameters (H.264/AAC for video, container codec for audio-only)
135
+ * so a following concat -c copy is stream-compatible. Used on passthrough pieces only — dubbed output is never touched. */
136
+ export async function normalizeTo(path, ref, outPath) {
137
+ const args = ['-y', '-i', path];
138
+ if (ref.video) {
139
+ const vf = [];
140
+ if (ref.video.width && ref.video.height) vf.push(`scale=${ref.video.width}:${ref.video.height}`);
141
+ if (ref.video.fps) vf.push(`fps=${ref.video.fps}`);
142
+ if (vf.length) args.push('-vf', vf.join(','));
143
+ args.push(...encoderVideoArgs(await pickVideoEncoder()));
144
+ if (ref.video.pixFmt) args.push('-pix_fmt', ref.video.pixFmt);
145
+ args.push('-c:a', 'aac', '-b:a', '128k');
146
+ } else {
147
+ args.push('-map', '0:a', ...audioCodecArgs(extname(outPath)));
148
+ }
149
+ if (ref.audio?.sampleRate) args.push('-ar', String(ref.audio.sampleRate));
150
+ if (ref.audio?.channels) args.push('-ac', String(ref.audio.channels));
151
+ if (ref.video) args.push('-movflags', '+faststart');
152
+ args.push(outPath);
153
+ await exec('ffmpeg', args, { maxBuffer: 1 << 20 });
154
+ return outPath;
155
+ }
156
+
117
157
  // ── segment splitting ───────────────────────────────────
118
158
  async function listParts(dir, ext) {
119
159
  const names = (await readdir(dir)).filter((n) => /^part_\d+/.test(n) && n.endsWith(ext)).sort();
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { ensureFfmpeg } from '../scripts/check_deps.mjs';
8
8
  import { makeTempDir } from './tmp.mjs';
9
- import { pickVideoEncoder, encoderVideoArgs } from './ffmpeg.mjs';
9
+ import { pickVideoEncoder, encoderVideoArgs, probeStreams, normalizeTo } from './ffmpeg.mjs';
10
10
 
11
11
  const exec = promisify(execFile);
12
12
 
@@ -52,7 +52,7 @@ export async function mergeGroups(results, { outDir } = {}) {
52
52
  const ext = (persoName && extname(persoName)) || '.mp4'; // result container (e.g. .wav for audio dubbing)
53
53
  const name = groups.length === 1 ? `output${ext}` : `output_${g + 1}${ext}`;
54
54
  const out = join(dir, name);
55
- await concat(group.map((i) => i.path), out);
55
+ await concat(await normalizeMixed(group, ext), out);
56
56
  outputs.push({ path: out, indices: group.map((i) => i.index), name: persoName });
57
57
  }
58
58
 
@@ -63,6 +63,27 @@ export async function mergeGroups(results, { outDir } = {}) {
63
63
  return { outputs, failures, report };
64
64
  }
65
65
 
66
+ // A group can mix Perso-encoded (OK) pieces with local original (PASSTHROUGH) pieces whose codec
67
+ // parameters rarely match — and concat -c copy does not validate stream parameters, so it can "succeed"
68
+ // into a file that plays back broken (boundary glitches, A/V desync). Re-encode ONLY the passthrough
69
+ // pieces to a dubbed piece's parameters; the paid dubbed output is concatenated untouched. Any failure
70
+ // here falls back to the original piece — concat's own re-encode fallback still guards the result.
71
+ async function normalizeMixed(group, ext) {
72
+ const dubbed = group.find((i) => i.status === 'OK' && i.path);
73
+ const pass = group.filter((i) => i.status === 'PASSTHROUGH' && i.path);
74
+ if (!dubbed || !pass.length) return group.map((i) => i.path);
75
+ ensureFfmpeg(); // mixed groups only exist for split videos, so ffmpeg was already installed for the split
76
+ const ref = await probeStreams(dubbed.path).catch(() => null);
77
+ if (!ref) return group.map((i) => i.path);
78
+ const dir = await makeTempDir('dubbing-norm-');
79
+ const normalized = new Map();
80
+ for (const p of pass) {
81
+ try { normalized.set(p, await normalizeTo(p.path, ref, join(dir, `norm_${p.index}${ext}`))); }
82
+ catch { /* keep the original piece */ }
83
+ }
84
+ return group.map((i) => normalized.get(i) ?? i.path);
85
+ }
86
+
66
87
  async function concat(paths, outPath) {
67
88
  if (paths.length === 1) {
68
89
  // single piece (no cutting) → copy as-is without ffmpeg
@@ -9,30 +9,25 @@ export const PRICING_URL = 'https://perso.ai/en/workspace/vt?pricing';
9
9
  export const UTM_SOURCE = CLIENT_HOST;
10
10
  export const UTM_PARAMS =
11
11
  `utm_source=${UTM_SOURCE}&utm_medium=agent-skill&utm_campaign=perso-dubbing&utm_content=v${CLIENT_VERSION}`;
12
- const withUtm = (url) => url + (url.includes('?') ? '&' : '?') + UTM_PARAMS;
13
-
14
- // free/starter have no credit purchase, so only a plan upgrade is suggested.
15
- const LOW_TIERS = new Set(['free', 'starter']);
12
+ export const withUtm = (url) => url + (url.includes('?') ? '&' : '?') + UTM_PARAMS;
16
13
 
17
14
  export const messages = {
18
- // Out-of-usage guidance. Depending on planTier: free/starter get upgrade-only, others get both upgrade and credits.
19
- // { planTier, remainingQuota, remainingNote, resumeHint }
20
- quotaExceeded: ({ planTier, remainingQuota, remainingNote, resumeHint } = {}) => {
21
- const isLow = LOW_TIERS.has(String(planTier ?? '').toLowerCase());
15
+ // Out-of-usage guidance. The agent can generate a direct Stripe link via scripts/billing.mjs, which
16
+ // routes by the current plan tier (free → subscribe · starter/creator → change plan · pro/business → credits).
17
+ // { planTier, remainingQuota, remainingNote, resumeHint, note }
18
+ quotaExceeded: ({ planTier, remainingQuota, remainingNote, resumeHint, note } = {}) => {
22
19
  const status =
23
20
  ` Current plan: ${planTier ?? 'unknown'} · Credits left: ${remainingQuota ?? '?'}` +
24
21
  (remainingNote ? ` · Remaining: ${remainingNote}` : '');
25
- const lines = [
22
+ return [
26
23
  'Out of usage/credits — only part of the work completed. The finished items are delivered above.',
27
24
  status,
25
+ ...(note ? [note] : []),
28
26
  '',
29
- ];
30
- if (isLow) {
31
- lines.push('To continue, upgrade your plan:', ` → ${withUtm(PRICING_URL)}`);
32
- } else {
33
- lines.push('To continue, upgrade your plan or buy more credits (Get credits), then run again:', ` → ${withUtm(SUBSCRIPTION_URL)}`);
34
- }
35
- if (resumeHint) lines.push(` Resume: ${resumeHint}`);
36
- return lines.join('\n');
27
+ 'To continue you can generate a payment link (routes by plan: subscribe / change plan / buy credits):',
28
+ ' → node scripts/billing.mjs options (add --shortfall <estimated remaining credits> for a recommendation)',
29
+ ' Then give the returned Stripe link to the user to complete payment in their browser — never pay on their behalf.',
30
+ ...(resumeHint ? [` Resume after topping up: ${resumeHint}`] : []),
31
+ ].join('\n');
37
32
  },
38
33
  };
@@ -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);
@@ -17,16 +17,23 @@ const MAX_DEPTH = 4;
17
17
  const SEG_MARGIN_MS = 2000; // SEG = cap − GOP − margin: a safety buffer to stay below the cap
18
18
  const MIN_SEG_MS = 3000; // if SEG is smaller than this (GOP too large relative to the cap), lossless is impossible → re-encode fallback
19
19
 
20
+ /** Thrown at depth 0 when a split is required but the user hasn't authorized it yet (no allowSplit).
21
+ * Carries what the caller needs to build the confirmation prompt: { reason:'length'|'size', limitMs?, actualMs?, actualBytes? }. */
22
+ export class SplitConfirmNeeded extends Error {
23
+ constructor(details) { super('split confirmation required'); this.name = 'SplitConfirmNeeded'; this.details = details; }
24
+ }
25
+
20
26
  /** @returns {{chunks:Array<{index,source,path?,sourceUrl?,mediaSeq?,startMs?,endMs?}>, notice:string|null}} */
21
27
  export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
22
28
  const log = hooks.log ?? (() => {});
23
29
  const notify = hooks.notify ?? (() => {}); // user-facing milestones (split start, etc.)
30
+ const allowSplit = hooks.allowSplit ?? false; // false → stop and ask before the first split (SplitConfirmNeeded)
24
31
  if (prepared.source === 'external') {
25
32
  return { chunks: [{ index: 0, source: 'external', sourceUrl: prepared.sourceUrl }], notice: null };
26
33
  }
27
34
 
28
35
  const localPath = prepared.localPath ?? prepared.path;
29
- const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify);
36
+ const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify, allowSplit);
30
37
  // carry each leaf's absolute boundaries (startMs/endMs) on the chunk → on resume, re-cut only that segment from the original.
31
38
  // when split, number the title _01,_02… → in the Perso project list, distinguish pieces of the same original (a single piece keeps the original name).
32
39
  const baseTitle = (prepared.originalName ?? basename(localPath)).replace(/\.[^.]+$/, '');
@@ -43,12 +50,13 @@ export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
43
50
  * startMs/spanMs are absolute positions relative to the original (an unsplit whole leaf has endMs=null).
44
51
  * Side optimization: a size overflow can be known in advance via local stat, so split a huge file directly instead of uploading it whole.
45
52
  */
46
- async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}) {
53
+ async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}, allowSplit = true) {
47
54
  if (depth < MAX_DEPTH) {
48
55
  const { size } = await stat(localPath);
49
- if (size > SIZE_CAP) { // size overflow → split before uploading
56
+ if (size > SIZE_CAP) { // size overflow → split before uploading (known locally, so no upload is wasted)
57
+ if (depth === 0 && !allowSplit) throw new SplitConfirmNeeded({ reason: 'size', actualBytes: size });
50
58
  log('The file is large, so the video is processed in parts. Cutting and merging takes a little while.');
51
- return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify);
59
+ return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify, allowSplit);
52
60
  }
53
61
  }
54
62
  try {
@@ -59,14 +67,27 @@ async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = n
59
67
  const code = e instanceof PersoApiError ? e.code : null;
60
68
  if (code !== 'F4008' && code !== 'F4004') throw e; // errors other than length/size are rethrown as-is
61
69
  if (depth >= MAX_DEPTH) throw new Error(`Split limit exceeded (depth ${depth}) — cannot upload`);
70
+ if (depth === 0 && !allowSplit) throw await splitConfirmFor(code, e, localPath);
62
71
  log('The video exceeds the length/size limit, so it is processed in parts. Cutting and merging takes a little while.');
63
72
  const lengthCapMs = code === 'F4008' ? Number(e.data?.maxLengthMs) : Infinity;
64
- return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify);
73
+ return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify, allowSplit);
74
+ }
75
+ }
76
+
77
+ // Build the confirmation error for a length (F4008) or size (F4004) rejection, best-effort filling the actual value.
78
+ async function splitConfirmFor(code, err, localPath) {
79
+ if (code === 'F4008') {
80
+ let actualMs = null;
81
+ try { ({ durationMs: actualMs } = await probe(localPath)); } catch { /* ffmpeg absent → omit the actual length */ }
82
+ return new SplitConfirmNeeded({ reason: 'length', limitMs: Number(err.data?.maxLengthMs) || null, actualMs });
65
83
  }
84
+ let actualBytes = null;
85
+ try { ({ size: actualBytes } = await stat(localPath)); } catch { /* omit the actual size */ }
86
+ return new SplitConfirmNeeded({ reason: 'size', actualBytes });
66
87
  }
67
88
 
68
89
  /** Split localPath by the length/size limit and recursively upload each piece. baseStartMs is the absolute offset relative to the original. */
69
- async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}) {
90
+ async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}, allowSplit = true) {
70
91
  ensureFfmpeg(); // install only when cutting is confirmed
71
92
  if (depth === 0) notify('Splitting — the video exceeds the length/size limit, so it is processed in parts.'); // user-facing
72
93
  const { durationMs, sizeBytes } = await probe(localPath);
@@ -79,7 +100,7 @@ async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCa
79
100
  for (let i = 0; i < parts.length; i++) {
80
101
  log(`Uploading piece ${i + 1}/${parts.length}...`);
81
102
  const p = parts[i];
82
- out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify)));
103
+ out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify, allowSplit)));
83
104
  }
84
105
  return out;
85
106
  }
@@ -0,0 +1,83 @@
1
+ // Once-a-day (UTC) check against the npm registry for a newer release. Both npx and the plugin marketplace
2
+ // install from npm, so the registry is the single source of truth. Non-blocking, fail-silent, throttled to
3
+ // at most one network call per UTC day; never delays or fails a run. Opt out with PERSO_NO_UPDATE_CHECK.
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { join } from 'node:path';
7
+ import { CRED_DIR } from './config.mjs';
8
+ import { CLIENT_VERSION } from './client_info.mjs';
9
+
10
+ const PKG = 'perso-dubbing';
11
+ const REGISTRY = `https://registry.npmjs.org/${PKG}/latest`;
12
+ const CACHE_FILE = join(CRED_DIR, 'update-check.json');
13
+ const TIMEOUT_MS = 3000;
14
+
15
+ const utcDate = () => new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
16
+
17
+ function readCache() {
18
+ try { return JSON.parse(readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
19
+ }
20
+ function writeCache(obj) {
21
+ try { mkdirSync(CRED_DIR, { recursive: true }); writeFileSync(CACHE_FILE, JSON.stringify(obj)); } catch { /* ignore */ }
22
+ }
23
+
24
+ // x.y.z compare: a>b → 1, a<b → -1, equal → 0. parseInt drops any pre-release/build suffix (e.g. "3-beta" → 3).
25
+ function cmpSemver(a, b) {
26
+ const part = (s) => String(s).split('.').map((n) => parseInt(n, 10) || 0);
27
+ const pa = part(a), pb = part(b);
28
+ for (let i = 0; i < 3; i++) {
29
+ const x = pa[i] || 0, y = pb[i] || 0;
30
+ if (x > y) return 1;
31
+ if (x < y) return -1;
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ async function fetchLatest() {
37
+ try {
38
+ const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(TIMEOUT_MS) });
39
+ if (!res.ok) return null;
40
+ const j = await res.json();
41
+ return typeof j?.version === 'string' ? j.version : null;
42
+ } catch { return null; }
43
+ }
44
+
45
+ // Best-effort install-method guess from the running file path (marketplace copies live under a plugins dir).
46
+ function installMethod() {
47
+ try {
48
+ const p = fileURLToPath(import.meta.url).replace(/\\/g, '/').toLowerCase();
49
+ if (p.includes('/plugins/') || p.includes('/marketplace')) return 'marketplace';
50
+ } catch { /* fall through */ }
51
+ return 'npx';
52
+ }
53
+
54
+ function buildNotice(latest) {
55
+ const lines = [`ℹ️ Update available: ${PKG} ${latest} (you have ${CLIENT_VERSION}).`];
56
+ if (installMethod() === 'marketplace') {
57
+ lines.push(' In Claude Code (plugin): /plugin update perso-dubbing');
58
+ lines.push(' Otherwise (npx / manual): npx perso-dubbing@latest');
59
+ } else {
60
+ lines.push(' Update: npx perso-dubbing@latest');
61
+ lines.push(' In Claude Code (plugin): /plugin update perso-dubbing');
62
+ }
63
+ return lines.join('\n');
64
+ }
65
+
66
+ /** Returns a one-line update notice (string) if a newer version exists, else null. Never throws.
67
+ * Fetches from npm at most once per UTC day (cached); reuses the last known result otherwise. */
68
+ export async function checkForUpdate() {
69
+ if (process.env.PERSO_NO_UPDATE_CHECK) return null;
70
+ if (CLIENT_VERSION === '0.0.0') return null; // version unknown → can't compare reliably
71
+
72
+ const today = utcDate();
73
+ const cache = readCache();
74
+ let latest = cache.latest ?? null;
75
+ if (cache.date !== today) {
76
+ const fetched = await fetchLatest();
77
+ latest = fetched ?? cache.latest ?? null; // keep last known on failure
78
+ writeCache({ date: today, latest }); // mark checked today regardless → ≤1 fetch/day
79
+ }
80
+
81
+ if (!latest || cmpSemver(latest, CLIENT_VERSION) <= 0) return null;
82
+ return buildNotice(latest);
83
+ }