perso-dubbing 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +3 -2
- package/README.md +12 -3
- package/package.json +2 -2
- package/skills/dubbing/SKILL.md +92 -22
- package/skills/dubbing/lib/api_adapter.mjs +69 -10
- package/skills/dubbing/lib/billing.mjs +159 -0
- package/skills/dubbing/lib/config.mjs +27 -1
- package/skills/dubbing/lib/ffmpeg.mjs +41 -1
- package/skills/dubbing/lib/merge.mjs +23 -2
- package/skills/dubbing/lib/messages.mjs +12 -17
- package/skills/dubbing/lib/scheduler.mjs +117 -15
- package/skills/dubbing/lib/split.mjs +28 -7
- package/skills/dubbing/lib/update_check.mjs +83 -0
- package/skills/dubbing/scripts/billing.mjs +220 -0
- package/skills/dubbing/scripts/dubbing.mjs +572 -47
- package/skills/dubbing/scripts/probe_split.mjs +1 -1
|
@@ -3,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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
4
|
-
"
|
|
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
|

|
|
7
7
|
[](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,
|
|
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).
|
|
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.
|
|
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": {
|
package/skills/dubbing/SKILL.md
CHANGED
|
@@ -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.
|
|
13
|
-
- **One command = the whole job.** `scripts/dubbing.mjs`
|
|
14
|
-
- **Always run `dubbing.mjs` in the background.** Jobs take minutes to hours; a foreground shell
|
|
15
|
-
-
|
|
16
|
-
- **Multiple languages in
|
|
17
|
-
- **Relay progress faithfully.** Surface the `[progress]` lines
|
|
18
|
-
- **Unsupported formats are skipped automatically
|
|
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
|
|
23
|
-
2. **ffmpeg/ffprobe** —
|
|
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 "<
|
|
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** —
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
- **
|
|
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`)
|
|
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
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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;
|