perso-dubbing 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,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.4.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.4.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.4.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.4.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
 
@@ -24,13 +24,16 @@ Because every host uses the same **Agent Skills standard** (`SKILL.md`), it work
24
24
 
25
25
  ## Install
26
26
 
27
- > **🤖 To the agent:** If you were given only this repository's URL, run this single command — it fetches the package, auto-detects every installed host (Claude Code · Codex · Cursor · Antigravity), and installs the skill into each:
27
+ > **🤖 To the agent:** If you were given only this repository's URL, pick the path for your host:
28
+ >
29
+ > - **Claude Code:** do **not** run npx right away — even if the user said "install this". Show the user the two plugin-marketplace commands below and ask them to type the commands themselves (they are user-typed slash commands you cannot execute). Fall back to npx only if the user still wants you to handle the install after seeing them.
30
+ > - **Codex · Cursor · Antigravity, or the user delegated the install to you:** run this single command — it fetches the package, auto-detects every installed host, and installs the skill into each:
28
31
  >
29
32
  > ```bash
30
33
  > npx perso-dubbing
31
34
  > ```
32
35
  >
33
- > If the repo is already cloned, run `node scripts/install.mjs` from the repo root instead (no network needed). In Claude Code, prefer the plugin-marketplace commands below.
36
+ > If the repo is already cloned, run `node scripts/install.mjs` from the repo root instead (no network needed).
34
37
 
35
38
  ### Claude Code (plugin marketplace — recommended)
36
39
 
@@ -116,6 +119,12 @@ npm run dub -- "clip.mp4" --target en,ja,zh
116
119
 
117
120
  # Several inputs at once (URLs, files, and folders can be mixed)
118
121
  npm run dub -- "https://youtu.be/..." "clip2.mp4" "C:\videos" --target en
122
+
123
+ # Dub + lip-sync (mouth matched to the dubbed audio; extra credits)
124
+ npm run dub -- "clip.mp4" --target en --lipsync
125
+
126
+ # Separate voice / background audio tracks (no dubbing)
127
+ npm run dub -- "clip.mp4" --separate
119
128
  ```
120
129
 
121
130
  *(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.4.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,128 @@ 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
+ **Split confirmation** if the input exceeds the length or size limit and must be auto split & merged (dubbing, dub+lip-sync, or audio separation), the worker stops (exit 3) and prints `[split-confirm]` lines. Relay them and ask the user: it exceeds the length/size limit, so it needs **automatic split → process → merge**, which can come out **less polished than splitting it up themselves** — proceed automatically? On a yes, re-run the **same command with `--allow-split`** (nothing is billed until this point, so re-running is free). Batch runs: `--allow-split` authorizes every split in the run.
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
+ While it runs (for explaining the wait):
38
+
39
+ - **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.
40
+ - **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.
41
+ - **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`….
42
+
43
+ ## Lip-sync
44
+
45
+ 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.
46
+
47
+ Pick the flow by what exists:
48
+
49
+ 1. **New video + lip-sync** — one command runs the whole chain (dub → lip-sync → save); warn that both stages bill:
50
+ `node scripts/dubbing.mjs "<file|URL>" --target en --lipsync`
51
+ 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.
52
+ 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):
53
+ `node scripts/dubbing.mjs --lipsync-only '<that [project-ref] JSON>'`
54
+ 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.
55
+
56
+ Rules:
57
+
58
+ - **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.
59
+ - **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.
60
+ - 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.
61
+
62
+ ## Audio separation
63
+
64
+ To split voice from background sound (no dubbing involved), run **in the background**:
65
+
66
+ `node scripts/dubbing.mjs --separate "<file|URL|folder>" [--space "space name"] [--out folder]`
67
+
68
+ - Outputs per input, next to the source (`--out` is a folder here): `<name>.voice.wav` · `<name>.background.wav` · `<name>.sub_background.wav`.
69
+ - Credits ≈ seconds ×0.5. No language options; cannot combine with lip-sync flags.
70
+ - Auto-split/merge, key gate, `[space-select]` and `[progress]` rules apply unchanged.
71
+ - **Resume** — separation saves the same `*.dubresume.json` state with a per-part checkpoint, so an interrupted run (credits, crash, killed shell) continues with `node scripts/dubbing.mjs --resume "<state-file>"` **without re-submitting already-paid parts**. Re-running the original command is blocked (`[resume-check]`, exit 3) — run the printed `--resume` instead.
41
72
 
42
73
  ## Interruption & resume
43
74
 
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:
75
+ 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
76
 
46
77
  ```
47
78
  node scripts/dubbing.mjs --resume "<state-file>"
48
79
  ```
49
80
 
50
- Completed parts are skipped automatically (same for single · multi-input · batch). The state file is deleted when everything finishes.
81
+ Completed parts are skipped automatically; the state file is deleted when everything finishes.
82
+
83
+ **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.
84
+
85
+ **On an insufficient-credits stop**: deliver the completed parts, then relay the worker's guidance and resume command **verbatim** — plain stdout (not `[progress]`), so don't drop it while summarizing. The guidance points to `scripts/billing.mjs` for a payment link — see **Plan upgrade & credits** below.
86
+
87
+ ## Plan upgrade & credits
88
+
89
+ When the user runs out of credits (the stop above) **or** asks to upgrade / buy more credits, you can generate a Stripe payment link. **You only ever hand the link to the user — never open it or complete payment yourself**, even if the user asks you to pay.
90
+
91
+ Run this first — it detects the plan and prints the fitting flow, the choices, and (with `--shortfall`) a recommendation:
92
+
93
+ `node scripts/billing.mjs options [--shortfall <estimated remaining credits>] [--space "<space name>"]`
94
+
95
+ It routes by the current plan tier — ask only the question for that branch, then generate the link:
96
+
97
+ - **free → subscribe.** Ask which plan and **monthly or yearly** (starter is monthly-only). Currency defaults to USD; use KRW only if the user asks.
98
+ `node scripts/billing.mjs link --checkout --plan <starter|creator|pro> --period <monthly|yearly> [--currency usd|krw]`
99
+ - **starter / creator → change plan.** Ask which plan; billing period and currency are locked to the existing subscription (handled automatically).
100
+ `node scripts/billing.mjs link --billing --plan <creator|pro>`
101
+ - **pro / business → buy credits.** Ask how many packs (1 pack = 60 credits, USD). With `--shortfall`, `options` recommends a quantity.
102
+ `node scripts/billing.mjs link --credits --quantity <n>`
103
+ - **enterprise → no self-serve.** Tell the user to contact their workspace administrator.
104
+
105
+ **Recommending on a credit-out stop**: estimate the remaining work's credits (dubbing ≈ ×1/s · lip-sync ≈ ×2 · separation ≈ ×0.5, and ×3 for 4K on pro+), pass it as `--shortfall`, and relay the tool's recommendation. If even the top self-serve plan or a reasonable credit quantity can't cover it, point the user to their administrator (Enterprise) instead.
106
+
107
+ Hand the returned link to the user to complete payment in their browser; after they top up, resume the interrupted job with the printed `--resume` command (no re-billing).
108
+
109
+ ## Perso portal (answer only when asked)
110
+
111
+ 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.
112
+
113
+ ## Version updates
114
+
115
+ Once a day (first run after 00:00 UTC) the worker checks npm for a newer release and, **after the current job finishes** (never mid-run), may print a one-line `ℹ️ Update available: …` notice. When you see it, relay it and act by install method:
116
+
117
+ - **Claude Code plugin (marketplace):** tell the user to run `/plugin update perso-dubbing` — a slash command you cannot run yourself.
118
+ - **npx / manual install:** ask the user whether to update, and on yes run `npx perso-dubbing@latest`.
51
119
 
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.)
120
+ The notice lists both commands; pick the one matching how it was installed. It never blocks a run and can be silenced with `PERSO_NO_UPDATE_CHECK=1`.
53
121
 
54
122
  ## Config (env)
55
123
 
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.
124
+ - `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).
125
+ - `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
126
  - `PERSO_SPACE_SEQ` — pin the space to use for every run (skips the space question).
59
127
  - `XP_API_KEY` — set the key directly (highest priority). Otherwise resolved from `~/.perso/credentials` (DPAPI-encrypted on Windows).
60
128
  - `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
129
  - `PERSO_NO_OPEN` — don't auto-open the key file in an editor during key registration (headless; the file path is still printed).
62
130
  - `PERSO_SIZE_CAP_BYTES` — upload size cap used for the split decision (default ≈1.9 GB, under the API's 2 GB limit).
63
131
  - `PERSO_QUEUE_WAIT_MS` — how long to wait between queue re-checks when all slots are occupied by other jobs (default 5 minutes).
132
+ - `PERSO_LIPSYNC_IDLE_MS` — no-progress allowance for a lip-sync job whose video length is unknown (default 3 hours).
133
+ - `PERSO_NO_UPDATE_CHECK` — skip the once-a-day npm version-update check (headless/CI, or to avoid the extra network call).
64
134
 
65
135
  ## Advanced (debug only — not part of the normal flow)
66
136
 
@@ -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;
@@ -0,0 +1,159 @@
1
+ // Plan-upgrade / credit-purchase helpers. All calls go to the Perso payment service with the same
2
+ // XP-API-KEY the rest of the skill uses; the agent only ever receives a Stripe link to hand to the user —
3
+ // it must never open or complete payment itself.
4
+ import { get, post, PersoApiError } from './http_client.mjs';
5
+
6
+ export const CURRENCIES = ['usd', 'krw'];
7
+ export const DEFAULT_CURRENCY = 'usd'; // international default for new subscriptions; KRW on request
8
+ export const ONE_TIME_CURRENCY = 'usd'; // credit packs are always charged in USD
9
+ // Credits granted per pack — used only to turn a credit shortfall into a pack quantity.
10
+ // Server billing is authoritative.
11
+ export const CREDIT_PER_UNIT = 60;
12
+ export const RETURN_URL = 'https://perso.ai/en/workspace/vt';
13
+ const SUCCESS_PARAMS = 's_seq={spaceSeq}';
14
+
15
+ // Low → high. Used to find which tiers are an upgrade from the current one.
16
+ const TIER_ORDER = ['free', 'starter', 'creator', 'pro', 'business', 'enterprise'];
17
+ const tierRank = (t) => TIER_ORDER.indexOf(String(t ?? '').toLowerCase());
18
+
19
+ /** Which purchase flow fits the current plan tier. */
20
+ export function actionForTier(tier) {
21
+ const t = String(tier ?? '').toLowerCase();
22
+ if (t === 'starter' || t === 'creator') return 'billing'; // has a subscription → change plan
23
+ if (t === 'pro' || t === 'business') return 'one-time'; // top self-serve → buy credits
24
+ if (t === 'enterprise') return 'contact'; // custom → contact admin, no self-serve
25
+ return 'checkout'; // free/unknown → new subscription
26
+ }
27
+
28
+ const extractLink = (res) => res?.link ?? res?.result?.link ?? null;
29
+
30
+ function normalizePlan(tier, p) {
31
+ return {
32
+ tier: p.tier ?? tier,
33
+ planSeq: p.planSeq,
34
+ planName: p.planName,
35
+ isCurrentPlan: !!p.isCurrentPlan,
36
+ isAccessible: p.isAccessible !== false,
37
+ isSwitchable: p.isSwitchable !== false,
38
+ priceOptions: (p.priceOptions ?? []).map((o) => ({
39
+ priceId: o.priceId,
40
+ price: o.price,
41
+ priceUnit: o.priceUnit,
42
+ billingPeriod: o.billingPeriod,
43
+ discountRate: o.discountRate ?? 0,
44
+ isCurrentPlan: !!o.isCurrentPlan,
45
+ })),
46
+ };
47
+ }
48
+
49
+ /** Recurring subscription plans for one billing period + currency, one entry per tier. */
50
+ export async function getRecurringPlans(spaceSeq, { billingPeriod, currency }) {
51
+ const res = await get('/payment/api/v1/plan/groups', {
52
+ query: { spaceSeq, productType: 'recurring', billingPeriod, currency },
53
+ });
54
+ const groups = res?.result ?? {};
55
+ const plans = [];
56
+ for (const [tier, arr] of Object.entries(groups)) {
57
+ const p = Array.isArray(arr) ? arr[0] : null;
58
+ if (p) plans.push(normalizePlan(tier, p));
59
+ }
60
+ return plans;
61
+ }
62
+
63
+ /** The one-time credit pack (productType=single, USD). Quantity-priced: unitPrice per pack. */
64
+ export async function getCreditProduct(spaceSeq) {
65
+ const res = await get('/payment/api/v1/plan', {
66
+ query: { spaceSeq, productType: 'single', currency: ONE_TIME_CURRENCY },
67
+ });
68
+ const p = (res?.result ?? [])[0] ?? null;
69
+ if (!p) return null;
70
+ const opt = (p.priceOptions ?? [])[0] ?? null;
71
+ return {
72
+ planSeq: p.planSeq,
73
+ planName: p.planName,
74
+ minQuantity: p.purchaseSeatCountMin ?? 1,
75
+ maxQuantity: p.purchaseSeatCountMax ?? -1, // -1 = unlimited
76
+ priceId: opt?.priceId ?? null,
77
+ unitPrice: opt?.price ?? null,
78
+ priceUnit: opt?.priceUnit ?? ONE_TIME_CURRENCY,
79
+ creditPerUnit: CREDIT_PER_UNIT,
80
+ };
81
+ }
82
+
83
+ /** Tiers strictly above `currentTier` that can be switched to (for the plan list to offer). */
84
+ export function upgradeCandidates(plans, currentTier) {
85
+ const rank = tierRank(currentTier);
86
+ return plans.filter((p) =>
87
+ tierRank(p.tier) > rank && p.tier !== 'enterprise' && p.isAccessible && p.isSwitchable && !p.isCurrentPlan);
88
+ }
89
+
90
+ /** Find a tier's price option for the exact billing period (null if that tier has no option for it). */
91
+ export function findPriceOption(plans, tier, billingPeriod) {
92
+ const plan = plans.find((p) => String(p.tier).toLowerCase() === String(tier).toLowerCase());
93
+ if (!plan) return null;
94
+ const opt = plan.priceOptions.find((o) => o.billingPeriod === billingPeriod);
95
+ return opt ? { planSeq: plan.planSeq, plan, opt } : null;
96
+ }
97
+
98
+ /** The billing period of the current subscription (billing requires the target to match it). */
99
+ export function currentBillingPeriod(plans) {
100
+ for (const p of plans) {
101
+ const cur = p.priceOptions.find((o) => o.isCurrentPlan);
102
+ if (cur) return cur.billingPeriod;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** Credit packs needed to cover a shortfall (each pack = CREDIT_PER_UNIT credits). */
108
+ export function recommendCredits(shortfall) {
109
+ const quantity = Math.max(1, Math.ceil(Number(shortfall) / CREDIT_PER_UNIT));
110
+ return { quantity, credits: quantity * CREDIT_PER_UNIT };
111
+ }
112
+
113
+ // --- link generation (each returns a Stripe URL string) ---
114
+
115
+ /** Free → new subscription. */
116
+ export async function createCheckoutLink({ spaceSeq, priceId, planSeq }) {
117
+ const res = await post('/payment/api/v1/charge/checkout', {
118
+ body: {
119
+ priceId, planSeq, spaceSeq,
120
+ successUrl: RETURN_URL, cancelUrl: RETURN_URL,
121
+ successParams: SUCCESS_PARAMS, isAddSpaceRequest: false, force: false,
122
+ },
123
+ });
124
+ return extractLink(res);
125
+ }
126
+
127
+ /** Existing subscriber → change plan (Stripe billing-portal preview; body is just these three). */
128
+ export async function createBillingLink({ spaceSeq, priceId, planSeq }) {
129
+ const res = await post('/payment/api/v1/charge/billing', { body: { priceId, planSeq, spaceSeq } });
130
+ return extractLink(res);
131
+ }
132
+
133
+ /** Subscriber → buy N credit packs (one-time checkout, USD). */
134
+ export async function createOneTimeLink({ spaceSeq, priceId, planSeq, quantity }) {
135
+ const res = await post('/payment/api/v1/charge/checkout/one-time', {
136
+ body: {
137
+ priceId, planSeq, quantity, spaceSeq,
138
+ successUrl: RETURN_URL, cancelUrl: RETURN_URL,
139
+ successParams: SUCCESS_PARAMS, isAddSpaceRequest: false, force: false,
140
+ },
141
+ });
142
+ return extractLink(res);
143
+ }
144
+
145
+ /** PAY4052 = Stripe session creation failed — for an existing subscriber this usually means the priceId's
146
+ * currency (or period) doesn't match the live subscription, so callers try the preferred currency then
147
+ * fall back to the other. */
148
+ export const isCurrencyMismatch = (err) => err instanceof PersoApiError && err.code === 'PAY4052';
149
+
150
+ /** Run `fn(currency)` for the preferred currency, falling back to the other on a currency mismatch. */
151
+ export async function withCurrencyFallback(preferred, fn) {
152
+ const order = preferred === 'krw' ? ['krw', 'usd'] : ['usd', 'krw'];
153
+ let lastErr = null;
154
+ for (const currency of order) {
155
+ try { return { currency, result: await fn(currency) }; }
156
+ catch (e) { if (isCurrencyMismatch(e)) { lastErr = e; continue; } throw e; }
157
+ }
158
+ throw lastErr;
159
+ }
@@ -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;