perso-dubbing 0.2.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 +22 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +14 -0
- package/.cursor-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +47 -0
- package/scripts/install.mjs +58 -0
- package/skills/dubbing/SKILL.md +72 -0
- package/skills/dubbing/agents/openai.yaml +7 -0
- package/skills/dubbing/lib/api_adapter.mjs +226 -0
- package/skills/dubbing/lib/client_info.mjs +24 -0
- package/skills/dubbing/lib/config.mjs +26 -0
- package/skills/dubbing/lib/ffmpeg.mjs +199 -0
- package/skills/dubbing/lib/http_client.mjs +99 -0
- package/skills/dubbing/lib/input.mjs +120 -0
- package/skills/dubbing/lib/languages.mjs +13 -0
- package/skills/dubbing/lib/mask.mjs +7 -0
- package/skills/dubbing/lib/merge.mjs +90 -0
- package/skills/dubbing/lib/messages.mjs +38 -0
- package/skills/dubbing/lib/scheduler.mjs +228 -0
- package/skills/dubbing/lib/space.mjs +51 -0
- package/skills/dubbing/lib/split.mjs +178 -0
- package/skills/dubbing/lib/tmp.mjs +21 -0
- package/skills/dubbing/scripts/check_deps.mjs +54 -0
- package/skills/dubbing/scripts/dubbing.mjs +534 -0
- package/skills/dubbing/scripts/languages.mjs +11 -0
- package/skills/dubbing/scripts/merge.mjs +12 -0
- package/skills/dubbing/scripts/prepare_input.mjs +12 -0
- package/skills/dubbing/scripts/probe_split.mjs +26 -0
- package/skills/dubbing/scripts/resolve_key.mjs +227 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Builds client runtime info (version, execution environment).
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const HERE = dirname(fileURLToPath(import.meta.url)); // <skill-root>/lib
|
|
7
|
+
const ROOT = dirname(HERE);
|
|
8
|
+
|
|
9
|
+
// Every agent host reports as one unified channel — API logs and UTM segment by version only.
|
|
10
|
+
export const CLIENT_HOST = 'agents';
|
|
11
|
+
|
|
12
|
+
// The skill sits either at the package root (installed copy) or under <repo>/skills/dubbing — check both for package.json.
|
|
13
|
+
function readVersion() {
|
|
14
|
+
for (const dir of [ROOT, join(ROOT, '..', '..')]) {
|
|
15
|
+
try {
|
|
16
|
+
const v = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version;
|
|
17
|
+
if (v) return v;
|
|
18
|
+
} catch { /* try next */ }
|
|
19
|
+
}
|
|
20
|
+
return '0.0.0';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CLIENT_VERSION = readVersion();
|
|
24
|
+
export const CLIENT_USER_AGENT = `perso-dubbing/${CLIENT_VERSION} (host=${CLIENT_HOST})`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Plugin's own constants. Plan info (MaxLen, C/Q, credits) is not kept here; it is discovered from API responses.
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// API
|
|
6
|
+
export const API_BASE = (process.env.PERSO_API_BASE || 'https://api.perso.ai').replace(/\/+$/, '');
|
|
7
|
+
export const AUTH_HEADER = 'XP-API-KEY';
|
|
8
|
+
|
|
9
|
+
// Polling / backoff
|
|
10
|
+
export const POLL_INTERVAL_MS = 5_000; // status polling interval (>=5s recommended)
|
|
11
|
+
export const BACKOFF_BASE_MS = 5_000; // VT5034 exponential backoff starting value
|
|
12
|
+
export const BACKOFF_MAX_MS = 60_000; // backoff upper bound
|
|
13
|
+
export const QUEUE_WAIT_MS = Number(process.env.PERSO_QUEUE_WAIT_MS) || 5 * 60_000; // recheck interval when no free slot is available (external/preceding jobs occupy the queue) — default 5 minutes
|
|
14
|
+
|
|
15
|
+
// Infinite-loop guard (plan-independent · plugin safeguard)
|
|
16
|
+
export const MAX_IDLE_MS = 30 * 60_000; // T: limit on no-progress (no submission/completion/progress%↑) (30 min). Not absolute elapsed time.
|
|
17
|
+
export const MAX_RETRY = 2; // number of retries for other per-chunk failures
|
|
18
|
+
|
|
19
|
+
// Media extensions — shared by the folder-input filter + choosing the video/audio endpoint at upload time.
|
|
20
|
+
// (Single-file input is accepted regardless of extension, and the upload step makes the final format determination.)
|
|
21
|
+
export const VIDEO_EXT = /\.(mp4|mov|webm|mkv|avi|m4v|wmv|flv|mpg|mpeg|ts|m2ts|3gp|ogv)$/i;
|
|
22
|
+
export const AUDIO_EXT = /\.(mp3|wav|m4a|aac|flac|ogg|oga|opus|wma|aif|aiff|alac)$/i;
|
|
23
|
+
|
|
24
|
+
// Credentials file
|
|
25
|
+
export const CRED_DIR = join(homedir(), '.perso');
|
|
26
|
+
export const CRED_FILE = join(CRED_DIR, 'credentials');
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// ffprobe/ffmpeg wrappers: measure duration/resolution, measure GOP, lossless segment splitting, re-encode fallback.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { platform } from 'node:os';
|
|
5
|
+
import { readdir } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { makeTempDir } from './tmp.mjs';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/** @returns {{durationMs?:number, width?:number, height?:number, sizeBytes?:number}} */
|
|
12
|
+
export async function probe(path) {
|
|
13
|
+
const args = [
|
|
14
|
+
'-v', 'error',
|
|
15
|
+
'-show_entries', 'format=duration,size',
|
|
16
|
+
'-show_entries', 'stream=width,height,codec_type',
|
|
17
|
+
'-of', 'json',
|
|
18
|
+
path,
|
|
19
|
+
];
|
|
20
|
+
const { stdout } = await exec('ffprobe', args, { maxBuffer: 1 << 20 });
|
|
21
|
+
const info = JSON.parse(stdout);
|
|
22
|
+
const durSec = Number(info?.format?.duration);
|
|
23
|
+
const sizeBytes = Number(info?.format?.size);
|
|
24
|
+
const v = (info?.streams ?? []).find((s) => s.codec_type === 'video') ?? {};
|
|
25
|
+
return {
|
|
26
|
+
durationMs: Number.isFinite(durSec) ? Math.round(durSec * 1000) : undefined,
|
|
27
|
+
width: v.width,
|
|
28
|
+
height: v.height,
|
|
29
|
+
sizeBytes: Number.isFinite(sizeBytes) ? sizeBytes : undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cut the [startMs,endMs) range to create outPath. Re-encode (keeping resolution/fps) for exact boundaries (avoids overlap).
|
|
35
|
+
* When maxBytes is given, cap the bitrate (-maxrate) to keep the piece size at or below it.
|
|
36
|
+
*/
|
|
37
|
+
export async function cut(path, startMs, endMs, outPath, maxBytes) {
|
|
38
|
+
const ss = (startMs / 1000).toFixed(3);
|
|
39
|
+
const durSec = (endMs - startMs) / 1000;
|
|
40
|
+
// -ss before -i = fast input seek + frame-accurate output via re-encoding (-c copy is keyframe-aligned and causes overlap)
|
|
41
|
+
const args = ['-y', '-ss', ss, '-i', path, '-t', durSec.toFixed(3), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20'];
|
|
42
|
+
if (maxBytes && durSec > 0) {
|
|
43
|
+
// total bitrate = maxBytes*8/dur. Use maxrate as the video cap, excluding the 128k audio.
|
|
44
|
+
// For short ranges the budget gets very large, so bufsize (=2*kbps*1000) exceeds libx264's int32 limit (2.147e9) → clamp the cap.
|
|
45
|
+
// (In that case the size constraint is non-binding, so clamping is harmless — short videos don't approach SIZE_CAP.)
|
|
46
|
+
const vKbps = Math.min(1_000_000, Math.max(100, Math.floor((maxBytes * 8) / 1000 / durSec) - 128));
|
|
47
|
+
args.push('-maxrate', `${vKbps}k`, '-bufsize', `${vKbps * 2}k`);
|
|
48
|
+
}
|
|
49
|
+
args.push('-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', outPath);
|
|
50
|
+
await exec('ffmpeg', args, { maxBuffer: 1 << 20 });
|
|
51
|
+
return outPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── GOP measurement ───────────────────────────────────────────
|
|
55
|
+
/** Estimate the maximum keyframe interval (GOP) in ms. Scans only the first scanSec (fast). Returns fallbackMs if measurement fails. */
|
|
56
|
+
export async function probeGop(path, { scanSec = 180, fallbackMs = 4000 } = {}) {
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await exec('ffprobe', [
|
|
59
|
+
'-v', 'error', '-select_streams', 'v:0',
|
|
60
|
+
'-read_intervals', `%+${scanSec}`,
|
|
61
|
+
'-show_entries', 'packet=pts_time,flags',
|
|
62
|
+
'-of', 'csv=print_section=0',
|
|
63
|
+
path,
|
|
64
|
+
], { maxBuffer: 32 << 20 });
|
|
65
|
+
const keys = [];
|
|
66
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
67
|
+
const m = /^([\d.]+),\s*K/.exec(line.trim()); // keyframe packet: flags start with K
|
|
68
|
+
if (m) keys.push(parseFloat(m[1]) * 1000);
|
|
69
|
+
}
|
|
70
|
+
keys.sort((a, b) => a - b);
|
|
71
|
+
let gap = 0;
|
|
72
|
+
for (let i = 1; i < keys.length; i++) gap = Math.max(gap, keys[i] - keys[i - 1]);
|
|
73
|
+
return gap > 0 ? Math.ceil(gap) : fallbackMs;
|
|
74
|
+
} catch {
|
|
75
|
+
return fallbackMs;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Re-encode encoder selection (fallback only): libx264 by default, HW auto-detected by OS ──
|
|
80
|
+
let _venc;
|
|
81
|
+
/** Auto-detect an available HW h264 encoder per OS (verifying it actually opens). Falls back to libx264 if none. Cached once. */
|
|
82
|
+
export async function pickVideoEncoder() {
|
|
83
|
+
if (_venc !== undefined) return _venc;
|
|
84
|
+
let enc = 'libx264';
|
|
85
|
+
try {
|
|
86
|
+
const { stdout } = await exec('ffmpeg', ['-hide_banner', '-encoders'], { maxBuffer: 4 << 20 });
|
|
87
|
+
const has = (n) => new RegExp(`\\b${n}\\b`).test(stdout);
|
|
88
|
+
const prefs = platform() === 'darwin' ? ['h264_videotoolbox']
|
|
89
|
+
: platform() === 'win32' ? ['h264_nvenc', 'h264_qsv', 'h264_amf']
|
|
90
|
+
: ['h264_nvenc', 'h264_qsv'];
|
|
91
|
+
for (const cand of prefs) {
|
|
92
|
+
if (has(cand) && (await encoderWorks(cand))) { enc = cand; break; } // included in build ≠ actually usable → runtime check
|
|
93
|
+
}
|
|
94
|
+
} catch { /* detection failed → libx264 */ }
|
|
95
|
+
_venc = enc;
|
|
96
|
+
return enc;
|
|
97
|
+
}
|
|
98
|
+
// Even if included in the build, it fails at runtime without the driver/HW (e.g. nvenc's nvcuda.dll) → verify with a 0.2s dummy encode.
|
|
99
|
+
async function encoderWorks(enc) {
|
|
100
|
+
try {
|
|
101
|
+
await exec('ffmpeg', ['-hide_banner', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=black:s=128x128:r=10:d=0.2',
|
|
102
|
+
...encoderVideoArgs(enc), '-f', 'null', '-'], { maxBuffer: 1 << 20 });
|
|
103
|
+
return true;
|
|
104
|
+
} catch { return false; }
|
|
105
|
+
}
|
|
106
|
+
/** Per-encoder quality args (for the re-encode fallback). */
|
|
107
|
+
export function encoderVideoArgs(enc) {
|
|
108
|
+
switch (enc) {
|
|
109
|
+
case 'h264_nvenc': return ['-c:v', 'h264_nvenc', '-preset', 'p4', '-cq', '23'];
|
|
110
|
+
case 'h264_qsv': return ['-c:v', 'h264_qsv', '-global_quality', '23'];
|
|
111
|
+
case 'h264_amf': return ['-c:v', 'h264_amf', '-rc', 'cqp', '-qp_i', '23', '-qp_p', '23'];
|
|
112
|
+
case 'h264_videotoolbox': return ['-c:v', 'h264_videotoolbox', '-q:v', '55'];
|
|
113
|
+
default: return ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20'];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── segment splitting ───────────────────────────────────
|
|
118
|
+
async function listParts(dir, ext) {
|
|
119
|
+
const names = (await readdir(dir)).filter((n) => /^part_\d+/.test(n) && n.endsWith(ext)).sort();
|
|
120
|
+
return names.map((n) => join(dir, n));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether a real video stream exists (excluding album-art attached_pic). Used to branch audio/video splitting. */
|
|
124
|
+
export async function probeHasVideo(path) {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v', '-show_entries', 'stream=codec_type:stream_disposition=attached_pic', '-of', 'json', path], { maxBuffer: 1 << 20 });
|
|
127
|
+
const streams = JSON.parse(stdout)?.streams ?? [];
|
|
128
|
+
return streams.some((s) => s.codec_type === 'video' && !s.disposition?.attached_pic);
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// For audio re-encoding, use a codec matching the container (.wav→PCM, etc.) — avoids breakage from putting aac into wav.
|
|
135
|
+
function audioCodecArgs(ext) {
|
|
136
|
+
if (/\.wav$/i.test(ext)) return ['-c:a', 'pcm_s16le'];
|
|
137
|
+
if (/\.mp3$/i.test(ext)) return ['-c:a', 'libmp3lame', '-q:a', '2'];
|
|
138
|
+
if (/\.(ogg|oga)$/i.test(ext)) return ['-c:a', 'libvorbis'];
|
|
139
|
+
if (/\.opus$/i.test(ext)) return ['-c:a', 'libopus'];
|
|
140
|
+
if (/\.flac$/i.test(ext)) return ['-c:a', 'flac'];
|
|
141
|
+
return ['-c:a', 'aac', '-b:a', '192k'];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Lossless (-c copy) split at segMs intervals. Video cuts at keyframe boundaries, audio (audioOnly) at frame boundaries. */
|
|
145
|
+
export async function segmentCopy(path, segMs, ext = '.mp4', { audioOnly = false } = {}) {
|
|
146
|
+
const dir = await tempDir();
|
|
147
|
+
const map = audioOnly ? ['-map', '0:a'] : ['-map', '0:v:0', '-map', '0:a:0?'];
|
|
148
|
+
await exec('ffmpeg', [
|
|
149
|
+
'-y', '-i', path, ...map, '-c', 'copy',
|
|
150
|
+
'-f', 'segment', '-segment_time', (segMs / 1000).toFixed(3),
|
|
151
|
+
'-reset_timestamps', '1',
|
|
152
|
+
join(dir, `part_%03d${ext}`),
|
|
153
|
+
], { maxBuffer: 1 << 20 });
|
|
154
|
+
return listParts(dir, ext);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Re-encode split at segMs intervals (fallback). Video: force keyframes at boundaries (HW→libx264). Audio: re-encode with the container codec. */
|
|
158
|
+
export async function segmentReencode(path, segMs, ext = '.mp4', maxBytes, { audioOnly = false } = {}) {
|
|
159
|
+
if (audioOnly) {
|
|
160
|
+
const dir = await tempDir();
|
|
161
|
+
await exec('ffmpeg', ['-y', '-i', path, '-map', '0:a', ...audioCodecArgs(ext), '-f', 'segment', '-segment_time', (segMs / 1000).toFixed(3), '-reset_timestamps', '1', join(dir, `part_%03d${ext}`)], { maxBuffer: 1 << 20 });
|
|
162
|
+
return listParts(dir, ext);
|
|
163
|
+
}
|
|
164
|
+
const enc = await pickVideoEncoder();
|
|
165
|
+
try {
|
|
166
|
+
return await runSegmentReencode(path, segMs, ext, maxBytes, enc);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (enc === 'libx264') throw e;
|
|
169
|
+
_venc = 'libx264'; // HW encoder failed at runtime → use libx264 from now on
|
|
170
|
+
return runSegmentReencode(path, segMs, ext, maxBytes, 'libx264');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function runSegmentReencode(path, segMs, ext, maxBytes, enc) {
|
|
174
|
+
const dir = await tempDir(); // a fresh folder per attempt (so leftovers from a previous attempt don't mix in)
|
|
175
|
+
const segSec = (segMs / 1000).toFixed(3);
|
|
176
|
+
const args = ['-y', '-i', path, ...encoderVideoArgs(enc), '-force_key_frames', `expr:gte(t,n_forced*${segSec})`];
|
|
177
|
+
if (maxBytes && segMs > 0) {
|
|
178
|
+
const vKbps = Math.min(1_000_000, Math.max(100, Math.floor((maxBytes * 8) / 1000 / (segMs / 1000)) - 128));
|
|
179
|
+
args.push('-maxrate', `${vKbps}k`, '-bufsize', `${vKbps * 2}k`);
|
|
180
|
+
}
|
|
181
|
+
args.push('-c:a', 'aac', '-b:a', '128k', '-f', 'segment', '-segment_time', segSec, '-reset_timestamps', '1', join(dir, `part_%03d${ext}`));
|
|
182
|
+
await exec('ffmpeg', args, { maxBuffer: 1 << 20 });
|
|
183
|
+
return listParts(dir, ext);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Lossless extraction (-c copy) of [startMs,endMs). Keyframe boundaries for video, frame boundaries for audio (for resume re-cutting). */
|
|
187
|
+
export async function cutCopy(path, startMs, endMs, outPath) {
|
|
188
|
+
const ss = (startMs / 1000).toFixed(3);
|
|
189
|
+
const t = ((endMs - startMs) / 1000).toFixed(3);
|
|
190
|
+
const hasVideo = await probeHasVideo(path);
|
|
191
|
+
const map = hasVideo ? ['-map', '0:v:0', '-map', '0:a:0?'] : ['-map', '0:a'];
|
|
192
|
+
const extra = hasVideo ? ['-movflags', '+faststart'] : [];
|
|
193
|
+
await exec('ffmpeg', ['-y', '-ss', ss, '-i', path, '-t', t, ...map, '-c', 'copy', '-reset_timestamps', '1', ...extra, outPath], { maxBuffer: 1 << 20 });
|
|
194
|
+
return outPath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function tempDir() {
|
|
198
|
+
return makeTempDir('dubbing-split-');
|
|
199
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// HTTP client. Attaches the XP-API-KEY header, waits on 429 rate limits, exposes error code/data.
|
|
2
|
+
// The raw key is used only in the header and is never printed anywhere.
|
|
3
|
+
import { API_BASE, AUTH_HEADER, BACKOFF_MAX_MS } from './config.mjs';
|
|
4
|
+
import { resolveKey } from '../scripts/resolve_key.mjs';
|
|
5
|
+
import { CLIENT_USER_AGENT, CLIENT_HOST } from './client_info.mjs';
|
|
6
|
+
|
|
7
|
+
/** An error that carries the code/data from the response as-is (e.g. F4008 → data.maxLengthMs, VT4021, A0010). */
|
|
8
|
+
export class PersoApiError extends Error {
|
|
9
|
+
constructor(httpStatus, code, message, data) {
|
|
10
|
+
super(message || code || `HTTP ${httpStatus}`);
|
|
11
|
+
this.name = 'PersoApiError';
|
|
12
|
+
this.httpStatus = httpStatus;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.data = data ?? null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class MissingKeyError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('XP_API_KEY not set — see scripts/resolve_key.mjs --check');
|
|
21
|
+
this.name = 'MissingKeyError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
|
|
27
|
+
async function parseBody(res) {
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
if (!text) return null;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
return { raw: text };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toError(httpStatus, body) {
|
|
38
|
+
const code = body?.code || body?.detailCode || body?.status;
|
|
39
|
+
return new PersoApiError(httpStatus, code, body?.message, body?.data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Perso API request. Returns parsed JSON on success; throws PersoApiError (with code/data) on failure.
|
|
44
|
+
* For 429/G0005, waits until X-RateLimit-Reset and then retries automatically.
|
|
45
|
+
*/
|
|
46
|
+
export async function request(method, path, { body, headers = {}, query } = {}) {
|
|
47
|
+
const key = resolveKey();
|
|
48
|
+
if (!key) throw new MissingKeyError();
|
|
49
|
+
|
|
50
|
+
let url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
|
51
|
+
if (query && Object.keys(query).length) {
|
|
52
|
+
const qs = new URLSearchParams(query).toString();
|
|
53
|
+
url += (url.includes('?') ? '&' : '?') + qs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const init = {
|
|
57
|
+
method,
|
|
58
|
+
headers: {
|
|
59
|
+
[AUTH_HEADER]: key,
|
|
60
|
+
'User-Agent': CLIENT_USER_AGENT,
|
|
61
|
+
'X-Perso-Client-Host': CLIENT_HOST,
|
|
62
|
+
...headers,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
if (body !== undefined) {
|
|
66
|
+
init.headers['Content-Type'] = 'application/json';
|
|
67
|
+
init.body = typeof body === 'string' ? body : JSON.stringify(body);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
71
|
+
const res = await fetch(url, init);
|
|
72
|
+
|
|
73
|
+
if (res.status === 429) {
|
|
74
|
+
const data = await parseBody(res);
|
|
75
|
+
const code = data?.code || data?.detailCode;
|
|
76
|
+
// A full queue (VT4292/FULL_VT_TRANSLATE_QUEUE) is a backpressure signal → don't blindly retry; surface it immediately.
|
|
77
|
+
// (The scheduler polls and waits for a free slot, then resubmits.)
|
|
78
|
+
if (code === 'VT4292' || code === 'VT5034' || data?.detailCode === 'FULL_VT_TRANSLATE_QUEUE') {
|
|
79
|
+
throw toError(res.status, data);
|
|
80
|
+
}
|
|
81
|
+
// Other ordinary rate limits → retry with exponential backoff
|
|
82
|
+
const reset = Number(res.headers.get('X-RateLimit-Reset'));
|
|
83
|
+
const waitMs = Number.isFinite(reset) && reset > 0
|
|
84
|
+
? Math.max(0, reset * 1000 - Date.now())
|
|
85
|
+
: Math.min(BACKOFF_MAX_MS, 2 ** attempt * 1000);
|
|
86
|
+
await sleep(Math.min(Math.max(waitMs, 1000), BACKOFF_MAX_MS));
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = await parseBody(res);
|
|
91
|
+
if (!res.ok) throw toError(res.status, data);
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
throw new PersoApiError(429, 'G0005', 'rate limit retries exceeded');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const get = (path, opts) => request('GET', path, opts);
|
|
98
|
+
export const post = (path, opts) => request('POST', path, opts);
|
|
99
|
+
export const put = (path, opts) => request('PUT', path, opts);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Input handling: local path validation / URL processing.
|
|
2
|
+
// - Local file → use as-is after existence check
|
|
3
|
+
// - Direct media URL → download to a local temp file, then merge into the flow
|
|
4
|
+
// - Platform URL (YouTube·TikTok·Drive·Vimeo) → delegate to the API external flow (no download)
|
|
5
|
+
import { stat, readdir } from 'node:fs/promises';
|
|
6
|
+
import { createWriteStream } from 'node:fs';
|
|
7
|
+
import { join, basename } from 'node:path';
|
|
8
|
+
import { makeTempDir } from './tmp.mjs';
|
|
9
|
+
import { VIDEO_EXT, AUDIO_EXT } from './config.mjs';
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
|
|
13
|
+
const isUrl = (s) => /^https?:\/\//i.test(s);
|
|
14
|
+
const PLATFORM_HOSTS = /(?:^|\.)(youtube\.com|youtu\.be|tiktok\.com|drive\.google\.com|vimeo\.com)$/i;
|
|
15
|
+
// Folder input picks only video+audio (media) files (excludes non-media such as outputs/manifests). Single-file input ignores the extension.
|
|
16
|
+
const isMedia = (name) => VIDEO_EXT.test(name) || AUDIO_EXT.test(name);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Expands the input(s) into a list of processing targets. If there are several, each is expanded and flattened.
|
|
20
|
+
* - URL/single file → 1 item
|
|
21
|
+
* - Folder → media files inside the folder (by extension). If recursive, includes subfolders too.
|
|
22
|
+
* - Multiple inputs (array) → expand each input by the rules above, then concatenate (URLs, paths, and folders can be mixed).
|
|
23
|
+
*/
|
|
24
|
+
export async function expandInputs(input, { recursive = false } = {}) {
|
|
25
|
+
const list = (Array.isArray(input) ? input : [input]).map((v) => (v ?? '').trim()).filter(Boolean);
|
|
26
|
+
if (!list.length) throw new Error('No input — provide a video file/folder path or a URL.');
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const value of list) out.push(...(await expandOne(value, recursive)));
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function expandOne(value, recursive) {
|
|
33
|
+
if (isUrl(value)) return [await prepareInput(value)];
|
|
34
|
+
|
|
35
|
+
let st;
|
|
36
|
+
try {
|
|
37
|
+
st = await stat(value);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new Error(`Path not found: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
if (st.isFile()) return [await prepareInput(value)];
|
|
42
|
+
if (st.isDirectory()) {
|
|
43
|
+
const files = await listVideos(value, recursive);
|
|
44
|
+
if (!files.length) throw new Error(`No media files in folder: ${value}`);
|
|
45
|
+
return files.map((f) => ({ source: 'local', localPath: f, originalName: basename(f), folderInput: true }));
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Unsupported input: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function listVideos(dir, recursive) {
|
|
51
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
const full = join(dir, e.name);
|
|
55
|
+
if (e.isDirectory()) {
|
|
56
|
+
if (recursive) out.push(...(await listVideos(full, true)));
|
|
57
|
+
} else if (isMedia(e.name)) {
|
|
58
|
+
out.push(full);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out.sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Normalizes the input string into the form the next step expects. Throws if there is no input (→ re-prompt). */
|
|
65
|
+
export async function prepareInput(input) {
|
|
66
|
+
const value = (input ?? '').trim();
|
|
67
|
+
if (!value) throw new Error('No input — provide a local video path or a URL.');
|
|
68
|
+
if (!isUrl(value)) return fromLocal(value);
|
|
69
|
+
|
|
70
|
+
let host = '';
|
|
71
|
+
try {
|
|
72
|
+
host = new URL(value).hostname;
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error(`Invalid URL: ${value}`);
|
|
75
|
+
}
|
|
76
|
+
return PLATFORM_HOSTS.test(host) ? { source: 'external', sourceUrl: value } : fromUrl(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fromLocal(path) {
|
|
80
|
+
let st;
|
|
81
|
+
try {
|
|
82
|
+
st = await stat(path);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`File not found: ${path}`);
|
|
85
|
+
}
|
|
86
|
+
if (!st.isFile()) throw new Error(`Not a file: ${path}`);
|
|
87
|
+
return { source: 'local', localPath: path, originalName: basename(path), bytes: st.size };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function fromUrl(url) {
|
|
91
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
92
|
+
if (!res.ok) throw new Error(`Failed to access URL (${res.status}): ${url}`);
|
|
93
|
+
if (!res.body) throw new Error(`URL response has no body: ${url}`);
|
|
94
|
+
|
|
95
|
+
const name = urlFileName(url, res.headers.get('content-type'));
|
|
96
|
+
const dir = await makeTempDir('dubbing-');
|
|
97
|
+
const localPath = join(dir, name);
|
|
98
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(localPath));
|
|
99
|
+
|
|
100
|
+
const st = await stat(localPath);
|
|
101
|
+
if (st.size === 0) throw new Error(`Downloaded result is empty: ${url}`);
|
|
102
|
+
return { source: 'url', sourceUrl: url, localPath, originalName: name, bytes: st.size };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function urlFileName(url, contentType) {
|
|
106
|
+
try {
|
|
107
|
+
const base = basename(new URL(url).pathname);
|
|
108
|
+
if (base && /\.[a-z0-9]{2,4}$/i.test(base)) return base;
|
|
109
|
+
} catch {
|
|
110
|
+
/* fall through */
|
|
111
|
+
}
|
|
112
|
+
const ext = /mp4/i.test(contentType ?? '')
|
|
113
|
+
? '.mp4'
|
|
114
|
+
: /webm/i.test(contentType ?? '')
|
|
115
|
+
? '.webm'
|
|
116
|
+
: /quicktime/i.test(contentType ?? '')
|
|
117
|
+
? '.mov'
|
|
118
|
+
: '.mp4';
|
|
119
|
+
return `input${ext}`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Fetch supported language codes.
|
|
2
|
+
import { get } from './http_client.mjs';
|
|
3
|
+
|
|
4
|
+
let _cache = null;
|
|
5
|
+
|
|
6
|
+
export async function getLanguages() {
|
|
7
|
+
if (_cache) return _cache;
|
|
8
|
+
const res = await get('/video-translator/api/v1/languages');
|
|
9
|
+
// Be flexible about the response shape: result[], languages[], or the array itself
|
|
10
|
+
const list = res?.result ?? res?.languages ?? res;
|
|
11
|
+
_cache = Array.isArray(list) ? list : [];
|
|
12
|
+
return _cache;
|
|
13
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Group merge. Accumulates OK/PASSTHROUGH in index order and splits at HARD_FAIL as group boundaries,
|
|
2
|
+
// then ffmpeg-concats each run of consecutive successes.
|
|
3
|
+
import { writeFile, rm, copyFile } from 'node:fs/promises';
|
|
4
|
+
import { join, extname } from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { ensureFfmpeg } from '../scripts/check_deps.mjs';
|
|
8
|
+
import { makeTempDir } from './tmp.mjs';
|
|
9
|
+
import { pickVideoEncoder, encoderVideoArgs } from './ffmpeg.mjs';
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
// Internal reason token → user-friendly text (avoids exposing raw codes/conditions)
|
|
14
|
+
function friendlyReason(reason) {
|
|
15
|
+
const map = {
|
|
16
|
+
too_long: 'length limit', submit_failed: 'processing error', download_failed: 'download failed',
|
|
17
|
+
no_voice: 'no voice detected (nothing to dub)', elapsed_exceeded: 'timed out', failed: 'processing failed',
|
|
18
|
+
};
|
|
19
|
+
if (map[reason]) return map[reason];
|
|
20
|
+
// Pass service-provided human messages through in any language (they contain spaces or non-ASCII);
|
|
21
|
+
// only unknown internal tokens (snake_case etc.) fall back to the generic text.
|
|
22
|
+
if (typeof reason === 'string' && (/\s/.test(reason.trim()) || /[^\x20-\x7E]/.test(reason))) return reason;
|
|
23
|
+
return 'processing failed';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param results Map(index→{status,path,reason}) or an array of the same shape
|
|
28
|
+
* @returns {outputs:[{path,indices}], failures:[{index,reason}], report:string|null}
|
|
29
|
+
*/
|
|
30
|
+
export async function mergeGroups(results, { outDir } = {}) {
|
|
31
|
+
const items = [...(results instanceof Map ? results.values() : results)].sort((a, b) => a.index - b.index);
|
|
32
|
+
const dir = outDir ?? (await makeTempDir('dubbing-merge-'));
|
|
33
|
+
|
|
34
|
+
const groups = [];
|
|
35
|
+
const failures = [];
|
|
36
|
+
let cur = [];
|
|
37
|
+
for (const it of items) {
|
|
38
|
+
if (it.status === 'OK' || it.status === 'PASSTHROUGH') {
|
|
39
|
+
cur.push(it);
|
|
40
|
+
} else {
|
|
41
|
+
// HARD_FAIL (generation failed) / DLFAIL (generated, only download failed) → a gap in this output (boundary split)
|
|
42
|
+
failures.push({ index: it.index, reason: it.reason ?? 'unknown' });
|
|
43
|
+
if (cur.length) { groups.push(cur); cur = []; }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (cur.length) groups.push(cur);
|
|
47
|
+
|
|
48
|
+
const outputs = [];
|
|
49
|
+
for (let g = 0; g < groups.length; g++) {
|
|
50
|
+
const group = groups[g];
|
|
51
|
+
const persoName = group.find((i) => i.name)?.name ?? null; // file name provided by Perso (if any)
|
|
52
|
+
const ext = (persoName && extname(persoName)) || '.mp4'; // result container (e.g. .wav for audio dubbing)
|
|
53
|
+
const name = groups.length === 1 ? `output${ext}` : `output_${g + 1}${ext}`;
|
|
54
|
+
const out = join(dir, name);
|
|
55
|
+
await concat(group.map((i) => i.path), out);
|
|
56
|
+
outputs.push({ path: out, indices: group.map((i) => i.index), name: persoName });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const report = failures.length
|
|
60
|
+
? failures.map((f) => `Part ${f.index + 1} excluded — ${friendlyReason(f.reason)}`).join('\n')
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
return { outputs, failures, report };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function concat(paths, outPath) {
|
|
67
|
+
if (paths.length === 1) {
|
|
68
|
+
// single piece (no cutting) → copy as-is without ffmpeg
|
|
69
|
+
await copyFile(paths[0], outPath);
|
|
70
|
+
return outPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ensureFfmpeg(); // multi-merge needs ffmpeg (usually already installed since splitting occurred)
|
|
74
|
+
const listFile = `${outPath}.concat.txt`;
|
|
75
|
+
const list = paths.map((p) => `file '${p.replace(/\\/g, '/').replace(/'/g, "'\\''")}'`).join('\n');
|
|
76
|
+
await writeFile(listFile, list, 'utf8');
|
|
77
|
+
|
|
78
|
+
const base = ['-y', '-f', 'concat', '-safe', '0', '-i', listFile];
|
|
79
|
+
try {
|
|
80
|
+
// when codec/resolution/sample rate match, do a lossless fast merge (-c copy)
|
|
81
|
+
await exec('ffmpeg', [...base, '-c', 'copy', '-movflags', '+faststart', outPath], { maxBuffer: 1 << 20 });
|
|
82
|
+
} catch {
|
|
83
|
+
// on mismatch, fall back to re-encoding (libx264 by default, HW auto-detected by OS)
|
|
84
|
+
const enc = await pickVideoEncoder();
|
|
85
|
+
await exec('ffmpeg', [...base, ...encoderVideoArgs(enc), '-c:a', 'aac', '-movflags', '+faststart', outPath], { maxBuffer: 1 << 20 });
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(listFile, { force: true }).catch(() => {}); // clean up the concat list file immediately
|
|
88
|
+
}
|
|
89
|
+
return outPath;
|
|
90
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// User-facing guidance message templates.
|
|
2
|
+
import { CLIENT_HOST, CLIENT_VERSION } from './client_info.mjs';
|
|
3
|
+
|
|
4
|
+
export const SUBSCRIPTION_URL = 'https://portal.perso.ai/en/workspace/space-settings?tab=Subscription';
|
|
5
|
+
export const PRICING_URL = 'https://perso.ai/en/workspace/vt?pricing';
|
|
6
|
+
|
|
7
|
+
// UTM identity — mirrors the API-call identity (User-Agent perso-dubbing/<version> (host=agents)):
|
|
8
|
+
// one unified 'agents' channel across all hosts; the skill version is carried in utm_content.
|
|
9
|
+
export const UTM_SOURCE = CLIENT_HOST;
|
|
10
|
+
export const UTM_PARAMS =
|
|
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']);
|
|
16
|
+
|
|
17
|
+
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());
|
|
22
|
+
const status =
|
|
23
|
+
` Current plan: ${planTier ?? 'unknown'} · Credits left: ${remainingQuota ?? '?'}` +
|
|
24
|
+
(remainingNote ? ` · Remaining: ${remainingNote}` : '');
|
|
25
|
+
const lines = [
|
|
26
|
+
'Out of usage/credits — only part of the work completed. The finished items are delivered above.',
|
|
27
|
+
status,
|
|
28
|
+
'',
|
|
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');
|
|
37
|
+
},
|
|
38
|
+
};
|