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 @@ 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
|
|
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.
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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)
|
|
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);
|
|
@@ -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
|
+
}
|