perso-dubbing 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,13 +3,13 @@
3
3
  "owner": {
4
4
  "name": "Perso AI"
5
5
  },
6
- "description": "Perso AI agent plugins — AI dubbing (video voice translation with lip-sync).",
6
+ "description": "Perso AI agent plugins — AI dubbing (video voice translation with lip-sync & audio separation).",
7
7
  "plugins": [
8
8
  {
9
9
  "name": "perso-dubbing",
10
10
  "source": { "source": "npm", "package": "perso-dubbing" },
11
- "description": "Auto-dub videos into other languages with lip-sync (Perso AI Dubbing). Single files, folders, URLs, auto split & merge for long media, multi-language output, resume.",
12
- "version": "0.2.0",
11
+ "description": "Auto-dub videos into other languages with lip-sync (Perso AI Dubbing), plus voice/background audio separation. Single files, folders, URLs, auto split & merge for long media, multi-language output, resume.",
12
+ "version": "0.3.0",
13
13
  "author": { "name": "Perso AI" },
14
14
  "homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
15
15
  "repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "description": "Auto-dub videos into other languages with lip-sync via the Perso AI Dubbing API. Handles single files, folders, URLs, oversized/long media (auto split & merge), multi-language output, and resume.",
4
- "version": "0.2.0",
3
+ "description": "Auto-dub videos into other languages with lip-sync via the Perso AI Dubbing API, plus voice/background audio separation. Handles single files, folders, URLs, oversized/long media (auto split & merge), multi-language output, and resume.",
4
+ "version": "0.3.0",
5
5
  "author": {
6
6
  "name": "Perso Dubbing"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "version": "0.2.0",
4
- "description": "Auto-dub videos into other languages with lip-sync via the Perso AI Dubbing API. Handles single files, folders, URLs, oversized/long media (auto split & merge), multi-language output, and resume.",
3
+ "version": "0.3.0",
4
+ "description": "Auto-dub videos into other languages with lip-sync via the Perso AI Dubbing API, plus voice/background audio separation. Handles single files, folders, URLs, oversized/long media (auto split & merge), multi-language output, and resume.",
5
5
  "skills": "./skills/",
6
6
  "author": "Perso AI",
7
7
  "homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "version": "0.2.0",
4
- "description": "Auto-dub videos into other languages with lip-sync via the Perso AI Dubbing API. Handles single files, folders, URLs, oversized/long media (auto split & merge), multi-language output, and resume.",
3
+ "displayName": "Perso AI Dubbing",
4
+ "version": "0.3.0",
5
+ "description": "Auto-dub videos into other languages with lip-sync via the Perso AI Dubbing API, plus voice/background audio separation. Handles single files, folders, URLs, oversized/long media (auto split & merge), multi-language output, and resume.",
5
6
  "author": { "name": "Perso AI" },
6
7
  "homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
7
8
  "repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  ![Platforms](https://img.shields.io/badge/platforms-Claude%20Code%20%C2%B7%20Antigravity%20%C2%B7%20Codex%20%C2%B7%20Cursor-555)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8
8
 
9
- A coding-agent skill that brings [Perso AI](https://perso.ai)'s **Dubbing (AI dubbing)** to your agent. It **auto-dubs** videos into other languages — a single file or a whole folder, and even oversized or very long media is automatically split, processed, and merged back together.
9
+ A coding-agent skill that brings [Perso AI](https://perso.ai)'s **Dubbing (AI dubbing)** to your agent. It **auto-dubs** videos into other languages — a single file or a whole folder, and even oversized or very long media is automatically split, processed, and merged back together. It can also **lip-sync** the dubbed video and **separate voice from background audio**.
10
10
 
11
11
  It calls the Perso Dubbing API under the hood, so **a Perso Dubbing API key is required.** → <a href="https://developers.perso.ai/api-keys" target="_blank" rel="noopener noreferrer">Get an API key</a>
12
12
 
@@ -116,6 +116,12 @@ npm run dub -- "clip.mp4" --target en,ja,zh
116
116
 
117
117
  # Several inputs at once (URLs, files, and folders can be mixed)
118
118
  npm run dub -- "https://youtu.be/..." "clip2.mp4" "C:\videos" --target en
119
+
120
+ # Dub + lip-sync (mouth matched to the dubbed audio; extra credits)
121
+ npm run dub -- "clip.mp4" --target en --lipsync
122
+
123
+ # Separate voice / background audio tracks (no dubbing)
124
+ npm run dub -- "clip.mp4" --separate
119
125
  ```
120
126
 
121
127
  *(Equivalent direct call: `node skills/dubbing/scripts/dubbing.mjs …` — or `node scripts/dubbing.mjs …` from inside an installed skill folder.)*
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "version": "0.2.0",
4
- "description": "Perso AI video auto-dubbing agent skill (Claude Code / Codex / Cursor / Antigravity)",
3
+ "version": "0.3.0",
4
+ "description": "Perso AI video auto-dubbing agent skill — dubbing, lip-sync, audio separation (Claude Code / Codex / Cursor / Antigravity)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -9,58 +9,94 @@ A skill that auto-dubs videos via the Perso AI Dubbing API.
9
9
 
10
10
  ## Core rules (must follow)
11
11
 
12
- - **Only the worker sees the raw key.** Never open the key with `Read`, echo it, or pass it as a command-line argument. The real key is used by the `lib/`·`scripts/` workers in the `XP-API-KEY` header only.
13
- - **One command = the whole job.** `scripts/dubbing.mjs` does everything: validate input → upload split only if needed → translate merge save. **Never run `prepare_input.mjs` or `probe_split.mjs` before it** — they are debug tools, and `probe_split.mjs` performs a real upload (running it first uploads the same video twice).
14
- - **Always run `dubbing.mjs` in the background.** Jobs take minutes to hours; a foreground shell with a timeout kills the run mid-way.
15
- - When languages are unspecified: source `auto`, target `en`.
16
- - **Multiple languages in a single command** — comma-separate them (`--target en,zh,ja`): split/upload happens once per video and the mediaSeq is reused per language. Running once per language re-uploads the same source once per language — avoid it.
17
- - **Relay progress faithfully.** Surface the `[progress]` lines the worker prints to stdout to the user's chat **verbatim (or summarized)**don't let them stay buried in background logs, and don't announce steps that didn't appear: splitting/merging happen only for videos that exceeded the limit, so an unsplit video is just "translation → save" (never promise a "merge" step for it). The two-space-indented stderr detail logs don't need relaying.
18
- - **Unsupported formats are skipped automatically.** If upload doesn't accept a format, that file is skipped and the rest keep processing. Relay the skip notice to the user too.
12
+ - **Only the worker sees the raw key.** Never open the key with `Read`, echo it, or pass it as a command-line argument. Workers send it in the `XP-API-KEY` header only.
13
+ - **One command = the whole job.** `scripts/dubbing.mjs` handles upload · split · translate · merge · save by itself. **Never run `prepare_input.mjs` or `probe_split.mjs` before it** — they are debug tools, and `probe_split.mjs` performs a real upload (the video would upload twice).
14
+ - **Always run `dubbing.mjs` in the background.** Jobs take minutes to hours; a foreground shell timeout kills the run mid-way.
15
+ - Languages unspecified source `auto`, target `en`.
16
+ - **Multiple languages go in one command** (`--target en,zh,ja`) — upload/split happens once and is reused per language. Never run once per language (re-uploads the source each time).
17
+ - **Relay progress faithfully.** Surface the worker's stdout `[progress]` lines to chat, verbatim or summarized — and don't announce steps that didn't appear (splitting/merging exist only for over-limit videos). The indented stderr detail logs don't need relaying.
18
+ - **Unsupported formats are skipped automatically** and the rest keep processing relay the skip notice.
19
19
 
20
20
  ## One-time setup
21
21
 
22
- 1. **API key** — no separate step needed: if no key is registered, `dubbing.mjs` opens a key file itself on first run (self-heal) — show the user the printed key-file path (as a **clickable** path) and tell them to "click to open, paste just the key, and save." It's encrypted on save and the file is deleted. To check/register ahead of time: `node scripts/resolve_key.mjs --check` (exit 2 = missing `--watch` registers the same way; don't run `--watch` if a dubbing run is already waiting for the key — two watchers would open two editors). Never paste the key into chat. Get a key: https://developers.perso.ai/api-keys
23
- 2. **ffmpeg/ffprobe** — no need to pre-install. Installed automatically **only when a video exceeds the plan's length limit and must be split** (approve only if permission is requested). Manual check: `node scripts/check_deps.mjs`.
22
+ 1. **API key** — no separate step: with no key registered, `dubbing.mjs` opens a key file on first run. Show the user the printed key-file path (clickable) and tell them to paste just the key and save (it's encrypted and the file deleted). Pre-check: `node scripts/resolve_key.mjs --check` (exit 2 = missing; `--watch` registers the same way never start it while a run is already waiting for the key). Never paste the key into chat. Get a key: https://developers.perso.ai/api-keys
23
+ 2. **ffmpeg/ffprobe** — auto-installed only when a video exceeds the plan limit and must be split (approve if permission is requested). Manual check: `node scripts/check_deps.mjs`.
24
24
 
25
25
  ## Run
26
26
 
27
27
  After the key gate, collect the input (local path or URL — re-ask if missing) and run **in the background**:
28
28
 
29
- - Single: `node scripts/dubbing.mjs "<file|URL>" [--source auto] [--target en] [--space "space name"] [--out result.mp4]`
30
- - Multi-language: `node scripts/dubbing.mjs "<file|URL>" --target en,zh,ja` — one output file per language.
31
- - Multiple inputs (URLs, files, and folders can be mixed): `node scripts/dubbing.mjs "<URL1>" "<URL2>" "<file>" --target en,ja` — results are saved per input × language next to each source (`--out <folder>` collects them into that folder).
29
+ - Single: `node scripts/dubbing.mjs "<file|URL>" [--source auto] [--target en] [--space "space name"] [--out result.mp4] [--lipsync]`
30
+ - Multi-language / multi-input (one or more inputs; URLs and files can be mixed): `node scripts/dubbing.mjs "<URL>" "<file>" --target en,ja` — one output per input × language, saved next to each source (`--out <folder>` collects them).
32
31
  - Folder (batch): `node scripts/dubbing.mjs "<folder>" [--target en,zh] [--recursive] [--out output-folder]`
33
32
 
34
- **Space selection** — if the account has several workspaces, the worker stops before uploading anything, prints `[space-select]` lines listing each space as **name | (plan) | remaining credits**, and exits (code 3). Show the user ONLY those options (no internal numbers), ask which one to dub in, then re-run the same command with `--space "<space name>"`. A single-space account proceeds without asking; `PERSO_SPACE_SEQ` pins the choice for every run.
33
+ **Space selection** — with several workspaces the worker stops before uploading, prints `[space-select]` lines (**name | (plan) | remaining credits**) and exits (code 3). Show the user ONLY those options (no internal numbers), ask which one, and re-run with `--space "<space name>"`. One space no question; `PERSO_SPACE_SEQ` pins it.
35
34
 
36
- What happens while it runs (so you can explain the waiting to the user):
35
+ While it runs (for explaining the wait):
37
36
 
38
- - **Upload-first split decision**: the whole file is uploaded first; only if the plan limit rejects it (`F4008`/size) is ffmpeg installed and the video split losslessly (`-c copy`, re-encode only when lossless isn't possible). External URLs (YouTube·TikTok·Drive·Vimeo) are handled server-side.
39
- - **One global queue**: all inputs × parts × languages fill a single pool. Only as many jobs as there are free slots are submitted; when the queue is fully occupied by other jobs, it re-checks every 5 minutes. An engine error on a part cancels that part's other languages; a silent (no-voice) part of a split video passes the original through; an idle guard prevents hanging forever.
40
- - **Merge & save**: consecutive successful parts are concatenated into one file per (input × language). Output names: a non-split single output keeps the **Perso download filename as-is**; a split-and-merged output is `<original-name>.dubbed.<lang>.<ext>`; on name collision, `_2`,`_3`….
37
+ - **Split**: the whole file is uploaded first; only a plan-limit rejection installs ffmpeg and splits losslessly. External URLs (YouTube·TikTok·Drive·Vimeo) are handled server-side.
38
+ - **Queue**: all inputs × parts × languages share one pool; a full queue is re-checked every 5 minutes. An engine error on a part cancels that part's other languages; a silent part passes the original through; an idle guard prevents hanging forever.
39
+ - **Save**: parts are merged back into one file per (input × language). An unsplit output keeps the Perso filename; a merged one is `<original-name>.dubbed.<lang>.<ext>`; collisions get `_2`,`_3`….
40
+
41
+ ## Lip-sync
42
+
43
+ Lip-sync (mouth matched to the dubbed audio) runs **after** dubbing, on the finished dubbing project. Video only — audio inputs are rejected. It is a long job: **run in the background and tell the user up-front it takes considerably longer than dubbing.** Credits (server billing is authoritative): dubbing ≈ seconds ×1 · lip-sync ≈ ×2 · both ≈ ×3 — dubbing now + lip-sync later costs the same as both at once. **4K+ sources: every rate ×3 on pro/business/enterprise plans** (e.g. a 1-min 4K dub+lip-sync ≈ 60×3×3 = 540) — mention this when the video is 4K.
44
+
45
+ Pick the flow by what exists:
46
+
47
+ 1. **New video + lip-sync** — one command runs the whole chain (dub → lip-sync → save); warn that both stages bill:
48
+ `node scripts/dubbing.mjs "<file|URL>" --target en --lipsync`
49
+ If `[credit-check]` lines print and it exits (code 3), the estimate exceeds the remaining credits: show those lines (top-up URL included), and re-run with `--force` only after the user tops up or approves continuing anyway.
50
+ 2. **Dubbed earlier in this session** — every finished run prints a `[project-ref] {...}` line. **Keep it; never show it to the user.** Lip-sync without re-dubbing (×2, no dubbing charge):
51
+ `node scripts/dubbing.mjs --lipsync-only '<that [project-ref] JSON>'`
52
+ 3. **No [project-ref] in this session** — ask if the user knows the project number from the Perso portal (`--lipsync-only <number>`, ×2). Otherwise the video must be dubbed again (`--lipsync`, ×3) — confirm before re-dubbing.
53
+
54
+ Rules:
55
+
56
+ - **Repeating lip-sync on the same project bills again** (no server-side dedup). If this session already lip-synced it, point at the existing file and re-run only on explicit confirmation.
57
+ - **If lip-sync fails, the worker saves the dubbed video instead** and says so in the final report — relay that clearly; the dubbing credits are not wasted.
58
+ - Credits running out between dubbing and lip-sync: the dubbed videos are saved and resume finishes only the lip-sync — relay the printed top-up URL and resume command verbatim.
59
+
60
+ ## Audio separation
61
+
62
+ To split voice from background sound (no dubbing involved), run **in the background**:
63
+
64
+ `node scripts/dubbing.mjs --separate "<file|URL|folder>" [--space "space name"] [--out folder]`
65
+
66
+ - Outputs per input, next to the source (`--out` is a folder here): `<name>.voice.wav` · `<name>.background.wav` · `<name>.sub_background.wav`.
67
+ - Credits ≈ seconds ×0.5. No language options; cannot combine with lip-sync flags.
68
+ - Auto-split/merge, key gate, `[space-select]` and `[progress]` rules apply unchanged.
69
+ - **No resume for separation yet** — re-running an interrupted run re-bills (parts run one at a time, so at most one part is at risk). Don't kill a running separation casually.
41
70
 
42
71
  ## Interruption & resume
43
72
 
44
- The worker saves a state file (`*.dubresume.json`, next to the source or `--out`) **from the moment the split plan is known and after every completed piece** so a run that dies for ANY reason (out of credits, crash, killed shell, Ctrl+C) can resume without redoing paid work:
73
+ The worker saves a state file (`*.dubresume.json`, next to the source or `--out`) from the moment the split plan is known and after every completed piece, so a run that dies for ANY reason (credits, crash, killed shell) resumes without redoing paid work:
45
74
 
46
75
  ```
47
76
  node scripts/dubbing.mjs --resume "<state-file>"
48
77
  ```
49
78
 
50
- Completed parts are skipped automatically (same for single · multi-input · batch). The state file is deleted when everything finishes.
79
+ Completed parts are skipped automatically; the state file is deleted when everything finishes.
80
+
81
+ **Re-running the original command while a state file exists is blocked** — the worker prints `[resume-check]` lines (exit 3) instead of re-billing. Relay the printed `--resume` command and run that. Delete the state file **only** if the user explicitly chooses to pay for the completed parts again — never on your own.
82
+
83
+ **On an insufficient-credits stop**: deliver the completed parts and relay the worker's upgrade/Get-credits URL and resume command **verbatim** — that URL is plain stdout (not `[progress]`), so don't drop it while summarizing.
84
+
85
+ ## Perso portal (answer only when asked)
51
86
 
52
- **When it stops due to insufficient usage (credits)**: deliver the completed parts and **surface the upgrade / buy-more-credits (Get credits) URL and the resume instructions printed by the worker, verbatim and without omission**. (That URL line is plain stdout, not a `[progress]` line, so it's easy to drop while summarizing never just say "top up credits and re-run" without the upgrade/credit path. Always include the URL.)
87
+ Every run is also a project in the user's Perso portal account. If the user wants more than the delivered files (subtitles, audio-only, other formats) or to browse/re-download earlier projects, point them to https://portal.perso.ai projects live in the workspace used for the run; split parts are numbered `_01`, `_02`, …. Never add this to progress relays or final reports.
53
88
 
54
89
  ## Config (env)
55
90
 
56
- - `PERSO_API_BASE` — API base URL (default `https://api.perso.ai`).
57
- - `PERSO_MEDIA_BASE` — media host for result files (default `https://portal-media.perso.ai`). Prepended when a response path is relative.
91
+ - `PERSO_API_BASE` — API base URL (default `https://api.perso.ai`). **https `perso.ai` hosts only** — anything else is rejected at startup (the API key travels in a header to this host).
92
+ - `PERSO_MEDIA_BASE` — media host for result files (default `https://portal-media.perso.ai`). Prepended when a response path is relative. Same https `perso.ai`-only rule.
58
93
  - `PERSO_SPACE_SEQ` — pin the space to use for every run (skips the space question).
59
94
  - `XP_API_KEY` — set the key directly (highest priority). Otherwise resolved from `~/.perso/credentials` (DPAPI-encrypted on Windows).
60
95
  - `PERSO_NO_WATCH` — when no key is registered, `dubbing.mjs` normally self-heals by opening a key file and waiting for the user to paste the key. Set this to fail fast instead (headless/CI).
61
96
  - `PERSO_NO_OPEN` — don't auto-open the key file in an editor during key registration (headless; the file path is still printed).
62
97
  - `PERSO_SIZE_CAP_BYTES` — upload size cap used for the split decision (default ≈1.9 GB, under the API's 2 GB limit).
63
98
  - `PERSO_QUEUE_WAIT_MS` — how long to wait between queue re-checks when all slots are occupied by other jobs (default 5 minutes).
99
+ - `PERSO_LIPSYNC_IDLE_MS` — no-progress allowance for a lip-sync job whose video length is unknown (default 3 hours).
64
100
 
65
101
  ## Advanced (debug only — not part of the normal flow)
66
102
 
@@ -3,11 +3,11 @@
3
3
  // Errors are thrown as-is via PersoApiError (with code/data) so the caller (scheduler) can branch on them.
4
4
  import { stat } from 'node:fs/promises';
5
5
  import { createWriteStream, createReadStream } from 'node:fs';
6
- import { basename } from 'node:path';
6
+ import { basename, extname } from 'node:path';
7
7
  import { Readable } from 'node:stream';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
  import { get, post, put, PersoApiError } from './http_client.mjs';
10
- import { AUDIO_EXT } from './config.mjs';
10
+ import { AUDIO_EXT, persoBaseUrl } from './config.mjs';
11
11
 
12
12
  const VT = '/video-translator/api/v1';
13
13
 
@@ -21,8 +21,9 @@ export class UnsupportedMediaError extends Error {
21
21
  }
22
22
  }
23
23
  // A file path in the response may be relative (perso-storage) → turn it into an absolute URL via the media base + encoding.
24
- // The media host can differ per environment, so it can be overridden with PERSO_MEDIA_BASE (defaults to production).
25
- const MEDIA_BASE = (process.env.PERSO_MEDIA_BASE || 'https://portal-media.perso.ai').replace(/\/+$/, '');
24
+ // The media host can differ per environment, so it can be overridden with PERSO_MEDIA_BASE (defaults to
25
+ // production) perso.ai hosts only, or an injected env could swap the delivered result files.
26
+ const MEDIA_BASE = persoBaseUrl('PERSO_MEDIA_BASE', process.env.PERSO_MEDIA_BASE, 'https://portal-media.perso.ai');
26
27
  const absolutize = (u) => {
27
28
  if (!u) return u;
28
29
  if (/^https?:\/\//i.test(u)) return u;
@@ -137,6 +138,49 @@ export async function requestTranslation(spaceSeq, mediaSeq, opts = {}) {
137
138
  return res?.result?.startGenerateProjectIdList ?? [];
138
139
  }
139
140
 
141
+ // ── audio separation ───────────────────────────────────
142
+ /** Request voice/background separation of an uploaded media → array of projectIds (same async project pattern as translate). */
143
+ export async function requestAudioSeparation(spaceSeq, mediaSeq, { title, kind = 'video' } = {}) {
144
+ if (!_queueInited.has(spaceSeq)) {
145
+ await put(`${VT}/projects/spaces/${spaceSeq}/queue`).catch(() => {});
146
+ _queueInited.add(spaceSeq);
147
+ }
148
+ const body = { mediaSeq, isVideoProject: kind !== 'audio', ...(title ? { title } : {}) };
149
+ const res = await post(`${VT}/projects/spaces/${spaceSeq}/audio-separation`, { body });
150
+ return res?.result?.startGenerateProjectIdList ?? [];
151
+ }
152
+
153
+ /** Download the separated tracks of a completed separation project. `download?target=` does not serve the
154
+ * voice/base-background tracks (server gap, verified 2026-07: VT4001/VT5001) — the project detail's
155
+ * downloadPathInfo does. outPathFor(label, ext) decides where each track is written. */
156
+ export async function downloadSeparation(projectSeq, spaceSeq, outPathFor) {
157
+ const p = await get(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}`);
158
+ const d = p?.downloadPathInfo ?? {};
159
+ const tracks = [
160
+ ['voice', d.originalVoicePath],
161
+ ['background', d.originalBackgroundPath],
162
+ ['sub_background', d.originalSubBackgroundPath],
163
+ ].filter(([, rel]) => rel);
164
+ if (!tracks.length) throw new Error('No separated tracks found on the project.');
165
+ const saved = [];
166
+ for (const [label, rel] of tracks) {
167
+ const link = absolutize(rel);
168
+ const fileName = decodeURIComponent(new URL(link).pathname.split('/').pop() || '') || null;
169
+ const outPath = outPathFor(label, extname(fileName ?? '') || '.wav');
170
+ await fetchToFile(link, outPath);
171
+ saved.push({ label, path: outPath, fileName });
172
+ }
173
+ return saved;
174
+ }
175
+
176
+ // ── requestLipSync ─────────────────────────────────────
177
+ /** Request lip-sync generation for a completed translation project → array of new projectIds (a lip-sync run is a separate project). */
178
+ export async function requestLipSync(projectSeq, spaceSeq, { speed = 'GREEN', title } = {}) {
179
+ const body = { preferredSpeedType: speed, ...(title ? { title } : {}) }; // title omitted → the parent project's title is used
180
+ const res = await post(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}/lip-sync`, { body });
181
+ return res?.result?.startGenerateProjectIdList ?? [];
182
+ }
183
+
140
184
  // ── getStatus ──────────────────────────────────────────
141
185
  /** Normalize based on progressReason. On failure, classify whether it's silence via engineErrorMessage from /progress. */
142
186
  export async function getStatus(projectSeq, spaceSeq) {
@@ -178,13 +222,28 @@ export async function cancel(projectSeq, spaceSeq) {
178
222
  }
179
223
 
180
224
  // ── download ───────────────────────────────────────────
181
- /** Check download-info download?target download link (save to outPath if needed). */
182
- export async function download(projectSeq, spaceSeq, { kind = 'video', outPath } = {}) {
183
- const isAudio = kind === 'audio';
184
- const target = isAudio ? 'voiceWithBackgroundAudio' : 'dubbingVideo'; // target for the audio-dubbing result
225
+ // Fallback link scan: any string value under a *DownloadLink key (payload field names vary per target).
226
+ function findDownloadLink(o) {
227
+ if (!o || typeof o !== 'object') return null;
228
+ for (const [k, v] of Object.entries(o)) {
229
+ if (typeof v === 'string' && /DownloadLink$/.test(k) && v) return v;
230
+ if (v && typeof v === 'object') {
231
+ const hit = findDownloadLink(v);
232
+ if (hit) return hit;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /** Check download-info → download?target → download link (save to outPath if needed). lipsync:true fetches the lip-sync video. */
239
+ export async function download(projectSeq, spaceSeq, { kind = 'video', outPath, lipsync = false } = {}) {
240
+ const isAudio = !lipsync && kind === 'audio';
241
+ const target = lipsync ? 'lipSyncVideo' : isAudio ? 'voiceWithBackgroundAudio' : 'dubbingVideo';
185
242
  const info = await get(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}/download-info`);
186
243
  const flags = info?.result ?? info;
187
- if (isAudio) {
244
+ if (lipsync) {
245
+ if (flags?.hasLipSyncVideo === false) throw new Error('Lip-sync video is not ready yet.');
246
+ } else if (isAudio) {
188
247
  if (flags?.hasTranslatedVoice === false && flags?.hasTranslateAudio === false) {
189
248
  throw new Error('Translated audio is not ready yet.');
190
249
  }
@@ -198,7 +257,7 @@ export async function download(projectSeq, spaceSeq, { kind = 'video', outPath }
198
257
  r.videoFile?.videoDownloadLink ??
199
258
  r.audioFile?.voiceWithBackgroundAudioDownloadLink ??
200
259
  r.zippedFileDownloadLink ??
201
- null;
260
+ findDownloadLink(r);
202
261
  if (!raw) throw new Error('Could not find a download link.');
203
262
  const link = absolutize(raw);
204
263
  const fileName = decodeURIComponent(new URL(link).pathname.split('/').pop() || '') || null;
@@ -3,7 +3,20 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
5
5
  // API
6
- export const API_BASE = (process.env.PERSO_API_BASE || 'https://api.perso.ai').replace(/\/+$/, '');
6
+ // PERSO_API_BASE / PERSO_MEDIA_BASE come from env, which a hostile repo config can plant for the agent —
7
+ // and every API call sends the key in a header to whatever host API_BASE names. Only https perso.ai hosts.
8
+ export function persoBaseUrl(name, raw, fallback) {
9
+ const v = (raw ?? '').trim();
10
+ if (!v) return fallback;
11
+ let u = null;
12
+ try { u = new URL(v); } catch { /* rejected below */ }
13
+ const host = u?.hostname ?? '';
14
+ if (u?.protocol !== 'https:' || (host !== 'perso.ai' && !host.endsWith('.perso.ai'))) {
15
+ throw new Error(`${name} must be an https:// URL on a perso.ai host (e.g. ${fallback}) — got "${v}"`);
16
+ }
17
+ return v.replace(/\/+$/, '');
18
+ }
19
+ export const API_BASE = persoBaseUrl('PERSO_API_BASE', process.env.PERSO_API_BASE, 'https://api.perso.ai');
7
20
  export const AUTH_HEADER = 'XP-API-KEY';
8
21
 
9
22
  // Polling / backoff
@@ -16,6 +29,19 @@ export const QUEUE_WAIT_MS = Number(process.env.PERSO_QUEUE_WAIT_MS) || 5 * 60_0
16
29
  export const MAX_IDLE_MS = 30 * 60_000; // T: limit on no-progress (no submission/completion/progress%↑) (30 min). Not absolute elapsed time.
17
30
  export const MAX_RETRY = 2; // number of retries for other per-chunk failures
18
31
 
32
+ // Lip-sync jobs run far longer than dubbing → poll less often and allow a longer no-progress window.
33
+ export const LIPSYNC_POLL_INTERVAL_MS = 30_000;
34
+ export const LIPSYNC_IDLE_PER_DURATION = 15; // no-progress allowance = video duration × this (when the duration is known)
35
+ export const LIPSYNC_IDLE_MS = Number(process.env.PERSO_LIPSYNC_IDLE_MS) || 3 * 60 * 60_000; // fallback allowance when the duration is unknown
36
+
37
+ // Credit pre-check estimate (per second of video). The server's billing is authoritative — these only power an upfront warning.
38
+ export const CREDIT_RATE_DUB = 1;
39
+ export const CREDIT_RATE_LIPSYNC = 2;
40
+ export const CREDIT_RATE_SEPARATION = 0.5;
41
+ // 4K+ sources are billed ×3 for dubbing/lip-sync on pro/business/enterprise plans only.
42
+ export const UHD_CREDIT_MULT = 3;
43
+ export const UHD_BILLED_TIERS = ['pro', 'business', 'enterprise'];
44
+
19
45
  // Media extensions — shared by the folder-input filter + choosing the video/audio endpoint at upload time.
20
46
  // (Single-file input is accepted regardless of extension, and the upload step makes the final format determination.)
21
47
  export const VIDEO_EXT = /\.(mp4|mov|webm|mkv|avi|m4v|wmv|flv|mpg|mpeg|ts|m2ts|3gp|ogv)$/i;
@@ -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,15 +9,15 @@ 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;
12
+ export const withUtm = (url) => url + (url.includes('?') ? '&' : '?') + UTM_PARAMS;
13
13
 
14
14
  // free/starter have no credit purchase, so only a plan upgrade is suggested.
15
15
  const LOW_TIERS = new Set(['free', 'starter']);
16
16
 
17
17
  export const messages = {
18
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 } = {}) => {
19
+ // { planTier, remainingQuota, remainingNote, resumeHint, note }
20
+ quotaExceeded: ({ planTier, remainingQuota, remainingNote, resumeHint, note } = {}) => {
21
21
  const isLow = LOW_TIERS.has(String(planTier ?? '').toLowerCase());
22
22
  const status =
23
23
  ` Current plan: ${planTier ?? 'unknown'} · Credits left: ${remainingQuota ?? '?'}` +
@@ -25,6 +25,7 @@ export const messages = {
25
25
  const lines = [
26
26
  'Out of usage/credits — only part of the work completed. The finished items are delivered above.',
27
27
  status,
28
+ ...(note ? [note] : []),
28
29
  '',
29
30
  ];
30
31
  if (isLow) {