perso-dubbing 0.3.0 → 0.5.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.
@@ -9,7 +9,7 @@
9
9
  "name": "perso-dubbing",
10
10
  "source": { "source": "npm", "package": "perso-dubbing" },
11
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",
12
+ "version": "0.5.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
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",
4
+ "version": "0.5.0",
5
5
  "author": {
6
6
  "name": "Perso Dubbing"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
3
  "displayName": "Perso AI Dubbing",
4
- "version": "0.3.0",
4
+ "version": "0.5.0",
5
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.",
6
6
  "author": { "name": "Perso AI" },
7
7
  "homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
package/README.md CHANGED
@@ -3,9 +3,11 @@
3
3
  [![Powered by Perso AI](https://img.shields.io/badge/Powered%20by-Perso%20AI-5A4FF3)](https://perso.ai)
4
4
  ![Node.js](https://img.shields.io/badge/Node.js-%E2%89%A518-339933?logo=node.js&logoColor=white)
5
5
  ![Agent Skills](https://img.shields.io/badge/Agent%20Skills-SKILL.md-1f6feb)
6
- ![Platforms](https://img.shields.io/badge/platforms-Claude%20Code%20%C2%B7%20Antigravity%20%C2%B7%20Codex%20%C2%B7%20Cursor-555)
6
+ ![Platforms](https://img.shields.io/badge/platforms-Claude%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
+ **English** | [한국어](docs/ko/README.md) | [Español](docs/es/README.md) | [Português](docs/pt/README.md) | [Русский](docs/ru/README.md) | [Bahasa Indonesia](docs/id/README.md) | [Deutsch](docs/de/README.md) | [ไทย](docs/th/README.md) | [日本語](docs/ja/README.md) | [繁體中文](docs/zh-TW/README.md) | [简体中文](docs/zh-CN/README.md) | [Tiếng Việt](docs/vi/README.md) | [Français](docs/fr/README.md)
10
+
9
11
  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
12
 
11
13
  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>
@@ -14,32 +16,49 @@ Because every host uses the same **Agent Skills standard** (`SKILL.md`), it work
14
16
 
15
17
  ---
16
18
 
17
- ## Quick start (first time)
19
+ ## 🖥️ Easiest way — the Claude desktop app (about 3 minutes)
20
+
21
+ > 📖 **Prefer pictures?** Follow the **[step-by-step visual guide →](https://est-perso-dubbing-agent.github.io/perso-dubbing-plugin/)** (English · 한국어)
22
+
23
+ No terminal needed. In the <a href="https://claude.ai/download" target="_blank" rel="noopener noreferrer">Claude desktop app</a> (paid plan):
24
+
25
+ 1. **Open the Code tab** (top center) and pick any folder — choose the **Local** environment (plugins are not available in cloud sessions).
26
+ 2. **Paste each command** into the prompt box and press Enter, one at a time:
27
+
28
+ ```text
29
+ /plugin marketplace add est-perso-dubbing-agent/perso-dubbing-plugin
30
+ ```
31
+
32
+ ```text
33
+ /plugin install perso-dubbing@perso-ai
34
+ ```
18
35
 
19
- 1. **Create an empty folder** and open your coding agent (Claude Code · Codex · Cursor · Antigravity) in it.
20
- 2. **Tell the agent to install the plugin** — paste this repository's URL and say *"install this"* (or run `npx perso-dubbing` yourself).
21
- 3. **Ask it to dub a video** — e.g. *"Dub this video into English — C:\videos\clip.mp4"* (a YouTube URL or a whole folder works too). On first run it will open a key file for your Perso API key. Unless you pass `--out`, results are saved next to the source video.
36
+ *Prefer clicking? After the first command, press the **+** button next to the prompt box **Plugins** **Add plugin** → install **perso-dubbing**.*
37
+ 3. **Ask for a dub** — e.g. *"Dub this video into English C:\videos\clip.mp4"* (a YouTube URL or a whole folder works too). On the first run a key file opens for your <a href="https://developers.perso.ai/api-keys" target="_blank" rel="noopener noreferrer">Perso API key</a> — paste the key into that file, **not into the chat**. Unless you pass `--out`, results are saved next to the source video.
38
+
39
+ > 💡 The skill itself runs on **Node.js 18+**. If your PC doesn't have it, the agent will say `node` was not found — just reply *"install Node.js for me"* (or grab the LTS from <a href="https://nodejs.org" target="_blank" rel="noopener noreferrer">nodejs.org</a>), then try again.
40
+
41
+ The same two `/plugin` commands also work in the **Claude Code CLI**.
22
42
 
23
43
  ---
24
44
 
25
- ## Install
45
+ ## Install on other hosts
46
+
47
+ The shortcut that works everywhere: paste this repository's URL into your agent and say *"install this."*
26
48
 
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:
49
+ > **🤖 To the agent:** If you were given only this repository's URL, pick the path for your host:
50
+ >
51
+ > - **Claude Code (desktop app or CLI):** do **not** run npx right away — even if the user said "install this". Show the user the two `/plugin` commands from the *Claude desktop app* section above and ask them to type the commands themselves, one per message (they are user-typed slash commands you cannot execute). Also mention the desktop-app click path: after the first command, **+ button → Plugins → Add plugin**. If you need to tailor the wording, `CLAUDE_CODE_ENTRYPOINT=cli` in your shell env suggests a terminal session (undocumented signal — when unsure, present both paths). Fall back to npx only if the user still wants you to handle the install after seeing them.
52
+ > - **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
53
  >
29
54
  > ```bash
30
55
  > npx perso-dubbing
31
56
  > ```
32
57
  >
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.
34
-
35
- ### Claude Code (plugin marketplace — recommended)
36
-
37
- ```text
38
- /plugin marketplace add est-perso-dubbing-agent/perso-dubbing-plugin
39
- /plugin install perso-dubbing@perso-ai
40
- ```
58
+ > If the repo is already cloned, run `node scripts/install.mjs` from the repo root instead (no network needed).
41
59
 
42
- ### Codex
60
+ <details>
61
+ <summary><b>Codex</b></summary>
43
62
 
44
63
  Codex reads skills from the shared Agent Skills folder. Run `npx perso-dubbing --codex`, or copy manually:
45
64
 
@@ -51,7 +70,10 @@ Codex reads skills from the shared Agent Skills folder. Run `npx perso-dubbing -
51
70
 
52
71
  The repo also ships a Codex plugin manifest (`.codex-plugin/plugin.json`) for marketplace-based installs.
53
72
 
54
- ### Cursor
73
+ </details>
74
+
75
+ <details>
76
+ <summary><b>Cursor</b></summary>
55
77
 
56
78
  Run `npx perso-dubbing --cursor`, or copy into:
57
79
 
@@ -62,7 +84,10 @@ Run `npx perso-dubbing --cursor`, or copy into:
62
84
 
63
85
  The repo ships a Cursor plugin manifest (`.cursor-plugin/plugin.json`) for the Cursor plugin marketplace.
64
86
 
65
- ### Antigravity
87
+ </details>
88
+
89
+ <details>
90
+ <summary><b>Antigravity</b></summary>
66
91
 
67
92
  Run `npx perso-dubbing --antigravity`, or copy into either location:
68
93
 
@@ -71,7 +96,10 @@ Run `npx perso-dubbing --antigravity`, or copy into either location:
71
96
  ~/.agents/skills/dubbing/ # Antigravity 2.0+ (shared Agent Skills folder)
72
97
  ```
73
98
 
74
- ### ⚡ One-line installer (any host)
99
+ </details>
100
+
101
+ <details>
102
+ <summary><b>⚡ One-line installer (any host)</b></summary>
75
103
 
76
104
  Detects which hosts you use and installs to all of them — no clone needed:
77
105
 
@@ -84,7 +112,10 @@ npx perso-dubbing
84
112
 
85
113
  Already have the repo cloned? `node scripts/install.mjs` from the repo root does the same without any network.
86
114
 
87
- ### 🔧 Manual install
115
+ </details>
116
+
117
+ <details>
118
+ <summary><b>🔧 Manual install</b></summary>
88
119
 
89
120
  Copy the skill folder into your host's skills directory under the name **`dubbing`**. From the repo root:
90
121
 
@@ -95,6 +126,8 @@ mkdir -p <skills_folder>/dubbing && cp -r skills/dubbing/* <skills_folder>/dubbi
95
126
 
96
127
  > 💡 Windows (PowerShell): `New-Item -ItemType Directory -Force <skills_folder>\dubbing; Copy-Item .\skills\dubbing\* <skills_folder>\dubbing\ -Recurse`
97
128
 
129
+ </details>
130
+
98
131
  After installing, type **`/dubbing`** in your agent or just say **"dub this video for me"** to run it.
99
132
 
100
133
  ---
@@ -130,21 +163,32 @@ npm run dub -- "clip.mp4" --separate
130
163
 
131
164
  ## Troubleshooting
132
165
 
166
+ More questions? See the **[FAQ](FAQ.md)**.
167
+
133
168
  | Symptom | Fix |
134
169
  |---|---|
135
- | Install/run fails | Requires **Node.js 18+**. Check with `node -v`. |
170
+ | Claude desktop app asks for Git (Windows) | The Code tab needs <a href="https://git-scm.com/downloads/win" target="_blank" rel="noopener noreferrer">Git for Windows</a> on first use. Install it, then restart the app. |
171
+ | `/plugin` commands or the Plugins menu do nothing | You are in a **cloud session** — plugins only work in **Local** (and SSH) sessions. Switch the environment to Local and retry. |
172
+ | `node` not found / install or run fails | The skill runs on **Node.js 18+** — check with `node -v`. If missing, install the LTS from <a href="https://nodejs.org" target="_blank" rel="noopener noreferrer">nodejs.org</a>, or simply ask Claude in the session to install it for you, then restart the app. |
136
173
  | No API key yet | Just run any dubbing command — a key file opens automatically; paste your key and save (it's encrypted and the file is deleted). Manual check: `npm run key:check`. **Do not paste the key into the chat.** → <a href="https://developers.perso.ai/api-keys" target="_blank" rel="noopener noreferrer">Get an API key</a> |
137
174
  | ffmpeg-related error | ffmpeg is normally installed automatically. If it fails, run `npm run doctor`. |
138
175
  | Stops midway (out of credits, crash, killed process) | Progress is saved to a `*.dubresume.json` state file throughout the run. Run the **`--resume "<state-file>"`** command shown in the notice to finish only the remaining parts (completed parts are skipped automatically). |
139
176
 
140
177
  ---
141
178
 
179
+ ## Privacy & Telemetry
180
+
181
+ `/dubbing` sends **anonymous** usage events to improve the skill — for example, which action ran (dub / lip-sync / separate), whether it succeeded, the language pair, app version, and OS. It is tagged only with a random per-install ID and never includes your API key, file names or media content, account/email, or workspace IDs.
182
+
183
+ ---
184
+
142
185
  ## Repository layout
143
186
 
144
187
  ```text
145
188
  .claude-plugin/ Claude Code plugin + marketplace manifests
146
189
  .codex-plugin/ Codex plugin manifest
147
190
  .cursor-plugin/ Cursor plugin manifest
191
+ docs/ Step-by-step visual install guide (GitHub Pages)
148
192
  skills/dubbing/ The skill itself (SKILL.md · lib/ · scripts/) — self-contained
149
193
  scripts/ Repo-level installer (install.mjs)
150
194
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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",
@@ -26,12 +26,16 @@ A skill that auto-dubs videos via the Perso AI Dubbing API.
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] [--lipsync]`
29
+ - Single: `node scripts/dubbing.mjs "<file|URL>" [--source auto] [--target en] [--space "space name"] [--out result.mp4] [--lipsync] [--no-save]`
30
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).
31
31
  - Folder (batch): `node scripts/dubbing.mjs "<folder>" [--target en,zh] [--recursive] [--out output-folder]`
32
32
 
33
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.
34
34
 
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.
36
+
37
+ **Don't save (server-only)** — when the user wants the video dubbed but **not** saved as a local file, add `--no-save`: the worker leaves the result in the user's Perso workspace and skips the download, printing `Kept on server, not saved: … → project <seq>` (the `[project-ref]` line is still emitted, so the dub can be lip-synced or retrieved later). **Single/unsplit videos only** — a split video's merged file needs a local download, so it is saved normally and the worker says so. Cannot be combined with `--lipsync` (the lip-synced video must be downloaded).
38
+
35
39
  While it runs (for explaining the wait):
36
40
 
37
41
  - **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.
@@ -66,7 +70,7 @@ To split voice from background sound (no dubbing involved), run **in the backgro
66
70
  - Outputs per input, next to the source (`--out` is a folder here): `<name>.voice.wav` · `<name>.background.wav` · `<name>.sub_background.wav`.
67
71
  - Credits ≈ seconds ×0.5. No language options; cannot combine with lip-sync flags.
68
72
  - 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.
73
+ - **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.
70
74
 
71
75
  ## Interruption & resume
72
76
 
@@ -80,12 +84,43 @@ Completed parts are skipped automatically; the state file is deleted when everyt
80
84
 
81
85
  **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
86
 
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.
87
+ **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.
88
+
89
+ ## Plan upgrade & credits
90
+
91
+ 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.
92
+
93
+ Run this first — it detects the plan and prints the fitting flow, the choices, and (with `--shortfall`) a recommendation:
94
+
95
+ `node scripts/billing.mjs options [--shortfall <estimated remaining credits>] [--space "<space name>"]`
96
+
97
+ It routes by the current plan tier — ask only the question for that branch, then generate the link:
98
+
99
+ - **free → subscribe.** Ask which plan and **monthly or yearly** (starter is monthly-only). Currency defaults to USD; use KRW only if the user asks.
100
+ `node scripts/billing.mjs link --checkout --plan <starter|creator|pro> --period <monthly|yearly> [--currency usd|krw]`
101
+ - **starter / creator → change plan.** Ask which plan; billing period and currency are locked to the existing subscription (handled automatically).
102
+ `node scripts/billing.mjs link --billing --plan <creator|pro>`
103
+ - **pro / business → buy credits.** Ask how many packs (1 pack = 60 credits, USD). With `--shortfall`, `options` recommends a quantity.
104
+ `node scripts/billing.mjs link --credits --quantity <n>`
105
+ - **enterprise → no self-serve.** Tell the user to contact their workspace administrator.
106
+
107
+ **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.
108
+
109
+ 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).
84
110
 
85
111
  ## Perso portal (answer only when asked)
86
112
 
87
113
  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.
88
114
 
115
+ ## Version updates
116
+
117
+ 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:
118
+
119
+ - **Claude Code plugin (marketplace):** tell the user to run `/plugin update perso-dubbing` — a slash command you cannot run yourself.
120
+ - **npx / manual install:** ask the user whether to update, and on yes run `npx perso-dubbing@latest`.
121
+
122
+ 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`.
123
+
89
124
  ## Config (env)
90
125
 
91
126
  - `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).
@@ -97,6 +132,8 @@ Every run is also a project in the user's Perso portal account. If the user want
97
132
  - `PERSO_SIZE_CAP_BYTES` — upload size cap used for the split decision (default ≈1.9 GB, under the API's 2 GB limit).
98
133
  - `PERSO_QUEUE_WAIT_MS` — how long to wait between queue re-checks when all slots are occupied by other jobs (default 5 minutes).
99
134
  - `PERSO_LIPSYNC_IDLE_MS` — no-progress allowance for a lip-sync job whose video length is unknown (default 3 hours).
135
+ - `PERSO_NO_UPDATE_CHECK` — skip the once-a-day npm version-update check (headless/CI, or to avoid the extra network call).
136
+ - `PERSO_NO_TELEMETRY` — turn off anonymous usage telemetry (opt-out). No account/key/file data is ever sent; see the README "Privacy & Telemetry" section.
100
137
 
101
138
  ## Advanced (debug only — not part of the normal flow)
102
139
 
@@ -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
+ }
@@ -11,29 +11,23 @@ export const UTM_PARAMS =
11
11
  `utm_source=${UTM_SOURCE}&utm_medium=agent-skill&utm_campaign=perso-dubbing&utm_content=v${CLIENT_VERSION}`;
12
12
  export const withUtm = (url) => url + (url.includes('?') ? '&' : '?') + UTM_PARAMS;
13
13
 
14
- // free/starter have no credit purchase, so only a plan upgrade is suggested.
15
- const LOW_TIERS = new Set(['free', 'starter']);
16
-
17
14
  export const messages = {
18
- // Out-of-usage guidance. Depending on planTier: free/starter get upgrade-only, others get both upgrade and credits.
15
+ // Out-of-usage guidance. The agent can generate a direct Stripe link via scripts/billing.mjs, which
16
+ // routes by the current plan tier (free → subscribe · starter/creator → change plan · pro/business → credits).
19
17
  // { planTier, remainingQuota, remainingNote, resumeHint, note }
20
18
  quotaExceeded: ({ planTier, remainingQuota, remainingNote, resumeHint, note } = {}) => {
21
- const isLow = LOW_TIERS.has(String(planTier ?? '').toLowerCase());
22
19
  const status =
23
20
  ` Current plan: ${planTier ?? 'unknown'} · Credits left: ${remainingQuota ?? '?'}` +
24
21
  (remainingNote ? ` · Remaining: ${remainingNote}` : '');
25
- const lines = [
22
+ return [
26
23
  'Out of usage/credits — only part of the work completed. The finished items are delivered above.',
27
24
  status,
28
25
  ...(note ? [note] : []),
29
26
  '',
30
- ];
31
- if (isLow) {
32
- lines.push('To continue, upgrade your plan:', ` → ${withUtm(PRICING_URL)}`);
33
- } else {
34
- lines.push('To continue, upgrade your plan or buy more credits (Get credits), then run again:', ` → ${withUtm(SUBSCRIPTION_URL)}`);
35
- }
36
- if (resumeHint) lines.push(` Resume: ${resumeHint}`);
37
- return lines.join('\n');
27
+ 'To continue you can generate a payment link (routes by plan: subscribe / change plan / buy credits):',
28
+ ' → node scripts/billing.mjs options (add --shortfall <estimated remaining credits> for a recommendation)',
29
+ ' Then give the returned Stripe link to the user to complete payment in their browser — never pay on their behalf.',
30
+ ...(resumeHint ? [` Resume after topping up: ${resumeHint}`] : []),
31
+ ].join('\n');
38
32
  },
39
33
  };
@@ -204,16 +204,22 @@ export async function runSchedule(chunks, spaceSeq, opts = {}, hooks = {}) {
204
204
  continue;
205
205
  }
206
206
  if (opts.lipsync && chunk.kind === 'audio') log(`${tag} is audio — lip-sync skipped, delivering the dubbed audio`);
207
- const out = join(outDir, `dub_${chunk.inputId}_${String(chunk.index).padStart(3, '0')}_${chunk.target}.mp4`);
208
- try {
209
- const dl = await download(pid, spaceSeq, { kind: chunk.kind, outPath: out });
210
- setResult(chunk, { status: 'OK', projectId: pid, path: out, name: dl.fileName });
211
- log(`${tag} done`);
212
- } catch {
213
- // generation succeeded (has projectSeq) — only download failed. Don't re-dub; mark it as DLFAIL to
214
- // preserve projectSeq re-download on resume (no regeneration).
215
- setResult(chunk, { status: 'DLFAIL', projectId: pid, reason: 'download_failed' });
216
- log(`${tag} generated (download failed → re-download on resume)`);
207
+ if (chunk.noDownload) {
208
+ // --no-save (server-only): the result stays on the server; record the projectSeq and skip download/merge/save.
209
+ setResult(chunk, { status: 'OK', projectId: pid, serverOnly: true });
210
+ log(`${tag} done kept on server (not downloaded)`);
211
+ } else {
212
+ const out = join(outDir, `dub_${chunk.inputId}_${String(chunk.index).padStart(3, '0')}_${chunk.target}.mp4`);
213
+ try {
214
+ const dl = await download(pid, spaceSeq, { kind: chunk.kind, outPath: out });
215
+ setResult(chunk, { status: 'OK', projectId: pid, path: out, name: dl.fileName });
216
+ log(`${tag} done`);
217
+ } catch {
218
+ // generation succeeded (has projectSeq) — only download failed. Don't re-dub; mark it as DLFAIL to
219
+ // preserve projectSeq → re-download on resume (no regeneration).
220
+ setResult(chunk, { status: 'DLFAIL', projectId: pid, reason: 'download_failed' });
221
+ log(`${tag} generated (download failed → re-download on resume)`);
222
+ }
217
223
  }
218
224
  } else if (st.noVoice) {
219
225
  // no voice detected. For a split chunk (has endMs), pass the original through → keep silent segments of a long video and dub·merge the rest.
@@ -17,16 +17,23 @@ const MAX_DEPTH = 4;
17
17
  const SEG_MARGIN_MS = 2000; // SEG = cap − GOP − margin: a safety buffer to stay below the cap
18
18
  const MIN_SEG_MS = 3000; // if SEG is smaller than this (GOP too large relative to the cap), lossless is impossible → re-encode fallback
19
19
 
20
+ /** Thrown at depth 0 when a split is required but the user hasn't authorized it yet (no allowSplit).
21
+ * Carries what the caller needs to build the confirmation prompt: { reason:'length'|'size', limitMs?, actualMs?, actualBytes? }. */
22
+ export class SplitConfirmNeeded extends Error {
23
+ constructor(details) { super('split confirmation required'); this.name = 'SplitConfirmNeeded'; this.details = details; }
24
+ }
25
+
20
26
  /** @returns {{chunks:Array<{index,source,path?,sourceUrl?,mediaSeq?,startMs?,endMs?}>, notice:string|null}} */
21
27
  export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
22
28
  const log = hooks.log ?? (() => {});
23
29
  const notify = hooks.notify ?? (() => {}); // user-facing milestones (split start, etc.)
30
+ const allowSplit = hooks.allowSplit ?? false; // false → stop and ask before the first split (SplitConfirmNeeded)
24
31
  if (prepared.source === 'external') {
25
32
  return { chunks: [{ index: 0, source: 'external', sourceUrl: prepared.sourceUrl }], notice: null };
26
33
  }
27
34
 
28
35
  const localPath = prepared.localPath ?? prepared.path;
29
- const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify);
36
+ const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify, allowSplit);
30
37
  // carry each leaf's absolute boundaries (startMs/endMs) on the chunk → on resume, re-cut only that segment from the original.
31
38
  // when split, number the title _01,_02… → in the Perso project list, distinguish pieces of the same original (a single piece keeps the original name).
32
39
  const baseTitle = (prepared.originalName ?? basename(localPath)).replace(/\.[^.]+$/, '');
@@ -43,12 +50,13 @@ export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
43
50
  * startMs/spanMs are absolute positions relative to the original (an unsplit whole leaf has endMs=null).
44
51
  * Side optimization: a size overflow can be known in advance via local stat, so split a huge file directly instead of uploading it whole.
45
52
  */
46
- async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}) {
53
+ async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}, allowSplit = true) {
47
54
  if (depth < MAX_DEPTH) {
48
55
  const { size } = await stat(localPath);
49
- if (size > SIZE_CAP) { // size overflow → split before uploading
56
+ if (size > SIZE_CAP) { // size overflow → split before uploading (known locally, so no upload is wasted)
57
+ if (depth === 0 && !allowSplit) throw new SplitConfirmNeeded({ reason: 'size', actualBytes: size });
50
58
  log('The file is large, so the video is processed in parts. Cutting and merging takes a little while.');
51
- return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify);
59
+ return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify, allowSplit);
52
60
  }
53
61
  }
54
62
  try {
@@ -59,14 +67,27 @@ async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = n
59
67
  const code = e instanceof PersoApiError ? e.code : null;
60
68
  if (code !== 'F4008' && code !== 'F4004') throw e; // errors other than length/size are rethrown as-is
61
69
  if (depth >= MAX_DEPTH) throw new Error(`Split limit exceeded (depth ${depth}) — cannot upload`);
70
+ if (depth === 0 && !allowSplit) throw await splitConfirmFor(code, e, localPath);
62
71
  log('The video exceeds the length/size limit, so it is processed in parts. Cutting and merging takes a little while.');
63
72
  const lengthCapMs = code === 'F4008' ? Number(e.data?.maxLengthMs) : Infinity;
64
- return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify);
73
+ return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify, allowSplit);
74
+ }
75
+ }
76
+
77
+ // Build the confirmation error for a length (F4008) or size (F4004) rejection, best-effort filling the actual value.
78
+ async function splitConfirmFor(code, err, localPath) {
79
+ if (code === 'F4008') {
80
+ let actualMs = null;
81
+ try { ({ durationMs: actualMs } = await probe(localPath)); } catch { /* ffmpeg absent → omit the actual length */ }
82
+ return new SplitConfirmNeeded({ reason: 'length', limitMs: Number(err.data?.maxLengthMs) || null, actualMs });
65
83
  }
84
+ let actualBytes = null;
85
+ try { ({ size: actualBytes } = await stat(localPath)); } catch { /* omit the actual size */ }
86
+ return new SplitConfirmNeeded({ reason: 'size', actualBytes });
66
87
  }
67
88
 
68
89
  /** Split localPath by the length/size limit and recursively upload each piece. baseStartMs is the absolute offset relative to the original. */
69
- async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}) {
90
+ async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}, allowSplit = true) {
70
91
  ensureFfmpeg(); // install only when cutting is confirmed
71
92
  if (depth === 0) notify('Splitting — the video exceeds the length/size limit, so it is processed in parts.'); // user-facing
72
93
  const { durationMs, sizeBytes } = await probe(localPath);
@@ -79,7 +100,7 @@ async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCa
79
100
  for (let i = 0; i < parts.length; i++) {
80
101
  log(`Uploading piece ${i + 1}/${parts.length}...`);
81
102
  const p = parts[i];
82
- out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify)));
103
+ out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify, allowSplit)));
83
104
  }
84
105
  return out;
85
106
  }