perso-dubbing 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +14 -0
- package/.cursor-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +47 -0
- package/scripts/install.mjs +58 -0
- package/skills/dubbing/SKILL.md +72 -0
- package/skills/dubbing/agents/openai.yaml +7 -0
- package/skills/dubbing/lib/api_adapter.mjs +226 -0
- package/skills/dubbing/lib/client_info.mjs +24 -0
- package/skills/dubbing/lib/config.mjs +26 -0
- package/skills/dubbing/lib/ffmpeg.mjs +199 -0
- package/skills/dubbing/lib/http_client.mjs +99 -0
- package/skills/dubbing/lib/input.mjs +120 -0
- package/skills/dubbing/lib/languages.mjs +13 -0
- package/skills/dubbing/lib/mask.mjs +7 -0
- package/skills/dubbing/lib/merge.mjs +90 -0
- package/skills/dubbing/lib/messages.mjs +38 -0
- package/skills/dubbing/lib/scheduler.mjs +228 -0
- package/skills/dubbing/lib/space.mjs +51 -0
- package/skills/dubbing/lib/split.mjs +178 -0
- package/skills/dubbing/lib/tmp.mjs +21 -0
- package/skills/dubbing/scripts/check_deps.mjs +54 -0
- package/skills/dubbing/scripts/dubbing.mjs +534 -0
- package/skills/dubbing/scripts/languages.mjs +11 -0
- package/skills/dubbing/scripts/merge.mjs +12 -0
- package/skills/dubbing/scripts/prepare_input.mjs +12 -0
- package/skills/dubbing/scripts/probe_split.mjs +26 -0
- package/skills/dubbing/scripts/resolve_key.mjs +227 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "perso-ai",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Perso AI"
|
|
5
|
+
},
|
|
6
|
+
"description": "Perso AI agent plugins — AI dubbing (video voice translation with lip-sync).",
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "perso-dubbing",
|
|
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",
|
|
13
|
+
"author": { "name": "Perso AI" },
|
|
14
|
+
"homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
|
|
15
|
+
"repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"category": "media",
|
|
18
|
+
"keywords": ["dubbing", "video-translation", "lip-sync", "perso-ai", "media"],
|
|
19
|
+
"tags": ["dubbing", "video", "translation", "lip-sync", "audio"]
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
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",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Perso Dubbing"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
|
|
9
|
+
"repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": ["dubbing", "video-translation", "lip-sync", "perso-ai", "media"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"skills": "./skills/",
|
|
6
|
+
"author": "Perso AI",
|
|
7
|
+
"homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
|
|
8
|
+
"repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": ["dubbing", "video-translation", "lip-sync", "perso-ai", "media"],
|
|
11
|
+
"interface": {
|
|
12
|
+
"displayName": "Perso AI Dubbing"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"author": { "name": "Perso AI" },
|
|
6
|
+
"homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
|
|
7
|
+
"repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": ["dubbing", "video-translation", "lip-sync", "perso-ai", "media"]
|
|
10
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perso Dubbing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# 🎬 /dubbing — Perso AI Video Auto-Dubbing
|
|
2
|
+
|
|
3
|
+
[](https://perso.ai)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
[](LICENSE)
|
|
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.
|
|
10
|
+
|
|
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
|
+
|
|
13
|
+
Because every host uses the same **Agent Skills standard** (`SKILL.md`), it works identically wherever you install it — just run `/dubbing` or say *"dub this video for me."*
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick start (first time)
|
|
18
|
+
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Install
|
|
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:
|
|
28
|
+
>
|
|
29
|
+
> ```bash
|
|
30
|
+
> npx perso-dubbing
|
|
31
|
+
> ```
|
|
32
|
+
>
|
|
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
|
+
```
|
|
41
|
+
|
|
42
|
+
### Codex
|
|
43
|
+
|
|
44
|
+
Codex reads skills from the shared Agent Skills folder. Run `npx perso-dubbing --codex`, or copy manually:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
~/.agents/skills/dubbing/ # personal (all repos) — current Codex
|
|
48
|
+
~/.codex/skills/dubbing/ # older Codex versions (the installer writes both)
|
|
49
|
+
<repo>/.agents/skills/dubbing/ # this repository only
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The repo also ships a Codex plugin manifest (`.codex-plugin/plugin.json`) for marketplace-based installs.
|
|
53
|
+
|
|
54
|
+
### Cursor
|
|
55
|
+
|
|
56
|
+
Run `npx perso-dubbing --cursor`, or copy into:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
~/.cursor/skills/dubbing/ # global
|
|
60
|
+
.cursor/skills/dubbing/ # this project only
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The repo ships a Cursor plugin manifest (`.cursor-plugin/plugin.json`) for the Cursor plugin marketplace.
|
|
64
|
+
|
|
65
|
+
### Antigravity
|
|
66
|
+
|
|
67
|
+
Run `npx perso-dubbing --antigravity`, or copy into either location:
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
~/.antigravity/skills/dubbing/ # Antigravity 1.x
|
|
71
|
+
~/.agents/skills/dubbing/ # Antigravity 2.0+ (shared Agent Skills folder)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### ⚡ One-line installer (any host)
|
|
75
|
+
|
|
76
|
+
Detects which hosts you use and installs to all of them — no clone needed:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx perso-dubbing
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- Specific host only: `--claude` / `--antigravity` / `--codex` / `--cursor` · all: `--all`
|
|
83
|
+
- Current project only (`./.claude`, `./.agents`, …): `--project`
|
|
84
|
+
|
|
85
|
+
Already have the repo cloned? `node scripts/install.mjs` from the repo root does the same without any network.
|
|
86
|
+
|
|
87
|
+
### 🔧 Manual install
|
|
88
|
+
|
|
89
|
+
Copy the skill folder into your host's skills directory under the name **`dubbing`**. From the repo root:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# macOS / Linux
|
|
93
|
+
mkdir -p <skills_folder>/dubbing && cp -r skills/dubbing/* <skills_folder>/dubbing/
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> 💡 Windows (PowerShell): `New-Item -ItemType Directory -Force <skills_folder>\dubbing; Copy-Item .\skills\dubbing\* <skills_folder>\dubbing\ -Recurse`
|
|
97
|
+
|
|
98
|
+
After installing, type **`/dubbing`** in your agent or just say **"dub this video for me"** to run it.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Examples
|
|
103
|
+
|
|
104
|
+
The easiest way — just tell your agent:
|
|
105
|
+
|
|
106
|
+
> "Dub this video into English — C:\videos\clip.mp4"
|
|
107
|
+
|
|
108
|
+
You can also run the CLI directly from the repo root:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# One video (auto-detect source → English)
|
|
112
|
+
npm run dub -- "clip.mp4" --target en --out result.mp4
|
|
113
|
+
|
|
114
|
+
# Several languages at once (uploaded/split once, reused per language)
|
|
115
|
+
npm run dub -- "clip.mp4" --target en,ja,zh
|
|
116
|
+
|
|
117
|
+
# Several inputs at once (URLs, files, and folders can be mixed)
|
|
118
|
+
npm run dub -- "https://youtu.be/..." "clip2.mp4" "C:\videos" --target en
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
*(Equivalent direct call: `node skills/dubbing/scripts/dubbing.mjs …` — or `node scripts/dubbing.mjs …` from inside an installed skill folder.)*
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Troubleshooting
|
|
126
|
+
|
|
127
|
+
| Symptom | Fix |
|
|
128
|
+
|---|---|
|
|
129
|
+
| Install/run fails | Requires **Node.js 18+**. Check with `node -v`. |
|
|
130
|
+
| 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> |
|
|
131
|
+
| ffmpeg-related error | ffmpeg is normally installed automatically. If it fails, run `npm run doctor`. |
|
|
132
|
+
| 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). |
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Repository layout
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
.claude-plugin/ Claude Code plugin + marketplace manifests
|
|
140
|
+
.codex-plugin/ Codex plugin manifest
|
|
141
|
+
.cursor-plugin/ Cursor plugin manifest
|
|
142
|
+
skills/dubbing/ The skill itself (SKILL.md · lib/ · scripts/) — self-contained
|
|
143
|
+
scripts/ Repo-level installer (install.mjs)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
This skill's code is distributed under the **[MIT License](LICENSE)**. The actual dubbing is performed through the Perso Dubbing API, so API usage itself is subject to the [Perso AI Terms of Service](https://perso.ai) and pricing.
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "perso-dubbing",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Perso AI video auto-dubbing agent skill (Claude Code / Codex / Cursor / Antigravity)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"perso-dubbing": "scripts/install.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"skills",
|
|
12
|
+
"scripts",
|
|
13
|
+
".claude-plugin",
|
|
14
|
+
".codex-plugin",
|
|
15
|
+
".cursor-plugin"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"agent-skills",
|
|
27
|
+
"skill",
|
|
28
|
+
"plugin",
|
|
29
|
+
"dubbing",
|
|
30
|
+
"video-translation",
|
|
31
|
+
"lip-sync",
|
|
32
|
+
"perso-ai",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"codex",
|
|
35
|
+
"cursor",
|
|
36
|
+
"antigravity"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"doctor": "node skills/dubbing/scripts/check_deps.mjs",
|
|
40
|
+
"key:check": "node skills/dubbing/scripts/resolve_key.mjs --check",
|
|
41
|
+
"langs": "node skills/dubbing/scripts/languages.mjs",
|
|
42
|
+
"input": "node skills/dubbing/scripts/prepare_input.mjs",
|
|
43
|
+
"split": "node skills/dubbing/scripts/probe_split.mjs",
|
|
44
|
+
"dub": "node skills/dubbing/scripts/dubbing.mjs",
|
|
45
|
+
"install:skill": "node scripts/install.mjs"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-click install: copy the dubbing skill into the skills folder of every detected host.
|
|
3
|
+
// npx github:est-perso-dubbing-agent/perso-dubbing-plugin → auto-install to hosts that have a config folder
|
|
4
|
+
// node scripts/install.mjs --all → all hosts
|
|
5
|
+
// ... --claude | --antigravity | --codex | --cursor → specific hosts only
|
|
6
|
+
// ... --project → install into the current folder (./.claude etc.)
|
|
7
|
+
import { cp, mkdir, access, writeFile, readFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); // package root (= parent of scripts/)
|
|
13
|
+
const SRC = join(ROOT, 'skills', 'dubbing'); // skill payload → installs under the name 'dubbing' (→ /dubbing)
|
|
14
|
+
const ITEMS = ['SKILL.md', 'lib', 'scripts', 'agents'];
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const baseDir = args.includes('--project') ? process.cwd() : homedir();
|
|
18
|
+
|
|
19
|
+
// probes: config dirs whose presence means the host is in use. dests: skills dirs to install into.
|
|
20
|
+
// '.agents/skills' is the cross-tool Agent Skills location (Codex · Antigravity 2.0 · Gemini CLI);
|
|
21
|
+
// '.codex/skills' is kept for older Codex versions that only scan their own folder.
|
|
22
|
+
const HOSTS = {
|
|
23
|
+
claude: { probes: ['.claude'], dests: [join('.claude', 'skills')] },
|
|
24
|
+
antigravity: { probes: ['.antigravity', '.gemini'], dests: [join('.antigravity', 'skills'), join('.agents', 'skills')] },
|
|
25
|
+
codex: { probes: ['.codex', '.agents'], dests: [join('.agents', 'skills'), join('.codex', 'skills')] },
|
|
26
|
+
cursor: { probes: ['.cursor'], dests: [join('.cursor', 'skills')] },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const exists = (p) => access(p).then(() => true, () => false);
|
|
30
|
+
|
|
31
|
+
let targets = Object.keys(HOSTS).filter((h) => args.includes(`--${h}`));
|
|
32
|
+
if (args.includes('--all')) targets = Object.keys(HOSTS);
|
|
33
|
+
if (!targets.length) {
|
|
34
|
+
for (const [h, { probes }] of Object.entries(HOSTS)) {
|
|
35
|
+
for (const probe of probes) {
|
|
36
|
+
if (await exists(join(baseDir, probe))) { targets.push(h); break; }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!targets.length) targets = ['claude'];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A minimal package.json goes with the copy so the worker can report its version (User-Agent) from the installed dir.
|
|
43
|
+
const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'));
|
|
44
|
+
const miniPkg = JSON.stringify({ name: pkg.name, version: pkg.version, description: pkg.description, type: 'module', license: pkg.license }, null, 2);
|
|
45
|
+
|
|
46
|
+
const dests = [...new Set(targets.flatMap((h) => HOSTS[h].dests))].map((d) => join(baseDir, d, 'dubbing'));
|
|
47
|
+
for (const dest of dests) {
|
|
48
|
+
await mkdir(dest, { recursive: true });
|
|
49
|
+
for (const item of ITEMS) {
|
|
50
|
+
await cp(join(SRC, item), join(dest, item), { recursive: true, force: true }).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
for (const doc of ['README.md', 'LICENSE']) {
|
|
53
|
+
await cp(join(ROOT, doc), join(dest, doc), { force: true }).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
await writeFile(join(dest, 'package.json'), miniPkg + '\n', 'utf8').catch(() => {});
|
|
56
|
+
console.log(`✅ ${dest}`);
|
|
57
|
+
}
|
|
58
|
+
console.log('\nInstalled! Use it in your agent with /dubbing or just "dub this video for me".');
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dubbing
|
|
3
|
+
description: Auto-dub (voice-translate) videos into other languages, with lip-sync (Perso AI Dubbing).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /dubbing
|
|
7
|
+
|
|
8
|
+
A skill that auto-dubs videos via the Perso AI Dubbing API.
|
|
9
|
+
|
|
10
|
+
## Core rules (must follow)
|
|
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.
|
|
19
|
+
|
|
20
|
+
## One-time setup
|
|
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`.
|
|
24
|
+
|
|
25
|
+
## Run
|
|
26
|
+
|
|
27
|
+
After the key gate, collect the input (local path or URL — re-ask if missing) and run **in the background**:
|
|
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).
|
|
32
|
+
- Folder (batch): `node scripts/dubbing.mjs "<folder>" [--target en,zh] [--recursive] [--out output-folder]`
|
|
33
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
What happens while it runs (so you can explain the waiting to the user):
|
|
37
|
+
|
|
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`….
|
|
41
|
+
|
|
42
|
+
## Interruption & resume
|
|
43
|
+
|
|
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:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
node scripts/dubbing.mjs --resume "<state-file>"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Completed parts are skipped automatically (same for single · multi-input · batch). The state file is deleted when everything finishes.
|
|
51
|
+
|
|
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.)
|
|
53
|
+
|
|
54
|
+
## Config (env)
|
|
55
|
+
|
|
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.
|
|
58
|
+
- `PERSO_SPACE_SEQ` — pin the space to use for every run (skips the space question).
|
|
59
|
+
- `XP_API_KEY` — set the key directly (highest priority). Otherwise resolved from `~/.perso/credentials` (DPAPI-encrypted on Windows).
|
|
60
|
+
- `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
|
+
- `PERSO_NO_OPEN` — don't auto-open the key file in an editor during key registration (headless; the file path is still printed).
|
|
62
|
+
- `PERSO_SIZE_CAP_BYTES` — upload size cap used for the split decision (default ≈1.9 GB, under the API's 2 GB limit).
|
|
63
|
+
- `PERSO_QUEUE_WAIT_MS` — how long to wait between queue re-checks when all slots are occupied by other jobs (default 5 minutes).
|
|
64
|
+
|
|
65
|
+
## Advanced (debug only — not part of the normal flow)
|
|
66
|
+
|
|
67
|
+
`dubbing.mjs` already does all of this internally; use these only to diagnose a problem:
|
|
68
|
+
|
|
69
|
+
- `node scripts/prepare_input.mjs "<input>"` — input normalization check (prints JSON).
|
|
70
|
+
- `node scripts/probe_split.mjs '<JSON|path>'` — upload-first split decision. ⚠ Performs a **real upload**; never use it as a pre-step to `dubbing.mjs`.
|
|
71
|
+
- `node scripts/languages.mjs` — list supported language codes.
|
|
72
|
+
- `node scripts/check_deps.mjs` — check/auto-install ffmpeg/ffprobe.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Perso API adapter.
|
|
2
|
+
// upload → requestTranslation → getStatus → download
|
|
3
|
+
// Errors are thrown as-is via PersoApiError (with code/data) so the caller (scheduler) can branch on them.
|
|
4
|
+
import { stat } from 'node:fs/promises';
|
|
5
|
+
import { createWriteStream, createReadStream } from 'node:fs';
|
|
6
|
+
import { basename } from 'node:path';
|
|
7
|
+
import { Readable } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { get, post, put, PersoApiError } from './http_client.mjs';
|
|
10
|
+
import { AUDIO_EXT } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
const VT = '/video-translator/api/v1';
|
|
13
|
+
|
|
14
|
+
// Format rejected by both the video and audio upload endpoints → used to suggest conversion.
|
|
15
|
+
export class UnsupportedMediaError extends Error {
|
|
16
|
+
constructor(fileName, cause) {
|
|
17
|
+
super(`Unsupported media format: ${fileName}`);
|
|
18
|
+
this.name = 'UnsupportedMediaError';
|
|
19
|
+
this.fileName = fileName;
|
|
20
|
+
this.cause = cause;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
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(/\/+$/, '');
|
|
26
|
+
const absolutize = (u) => {
|
|
27
|
+
if (!u) return u;
|
|
28
|
+
if (/^https?:\/\//i.test(u)) return u;
|
|
29
|
+
return MEDIA_BASE + encodeURI(u.startsWith('/') ? u : `/${u}`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── upload ──────────────────────────────────────────────
|
|
33
|
+
/** prepared({source,localPath|sourceUrl,originalName}) → { seq:mediaSeq, kind:'video'|'audio' } */
|
|
34
|
+
export async function upload(prepared, spaceSeq) {
|
|
35
|
+
if (prepared.source === 'external') {
|
|
36
|
+
const r = await put('/file/api/upload/video/external', {
|
|
37
|
+
body: { space_seq: spaceSeq, url: prepared.sourceUrl },
|
|
38
|
+
});
|
|
39
|
+
return { seq: r.seq, kind: 'video' };
|
|
40
|
+
}
|
|
41
|
+
return uploadLocal(prepared.localPath, prepared.originalName, spaceSeq);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function uploadLocal(localPath, fileName, spaceSeq) {
|
|
45
|
+
const name = fileName || basename(localPath);
|
|
46
|
+
|
|
47
|
+
// 1) SAS token (fileName is encoded by URLSearchParams)
|
|
48
|
+
const sas = await get('/file/api/upload/sas-token', { query: { fileName: name } });
|
|
49
|
+
const blobSasUrl = sas?.blobSasUrl;
|
|
50
|
+
if (!blobSasUrl) throw new Error('Failed to get an upload token — check your API key and permissions.');
|
|
51
|
+
|
|
52
|
+
// 2) PUT directly to Azure Blob (without XP-API-KEY).
|
|
53
|
+
// Stream the file instead of loading it entirely into memory → safe even for files over 2GiB (the readFile limit).
|
|
54
|
+
const { size } = await stat(localPath);
|
|
55
|
+
const blobRes = await fetch(blobSasUrl, {
|
|
56
|
+
method: 'PUT',
|
|
57
|
+
headers: { 'x-ms-blob-type': 'BlockBlob', 'Content-Length': String(size) },
|
|
58
|
+
body: createReadStream(localPath),
|
|
59
|
+
duplex: 'half', // undici: required for streaming the request body
|
|
60
|
+
});
|
|
61
|
+
if (!blobRes.ok) throw new Error(`Azure upload failed (${blobRes.status})`);
|
|
62
|
+
|
|
63
|
+
// 3) Register — decide by extension first: for an audio extension, go straight to the audio endpoint (skip the unnecessary video attempt).
|
|
64
|
+
// Otherwise (video/unknown), register as video, and if there's no video stream (F4007), fall back to audio → safe even if the extension is wrong.
|
|
65
|
+
// If both video and audio are rejected, it's an unsupported format (UnsupportedMediaError) → the caller suggests conversion.
|
|
66
|
+
const fileUrl = blobSasUrl.split('?')[0];
|
|
67
|
+
const reg = async (k) => {
|
|
68
|
+
const r = await put(`/file/api/upload/${k}`, { body: { spaceSeq, fileUrl, fileName: name } });
|
|
69
|
+
return { seq: r.seq, kind: k };
|
|
70
|
+
};
|
|
71
|
+
// Only length/size errors (F4008/F4004) are thrown as-is so the caller can split. Other register rejections (401, audio failure after F4007, etc.)
|
|
72
|
+
// are treated as unsupported formats — if the key were wrong it would already have failed at the SAS token above, so a 401 here is not an auth problem.
|
|
73
|
+
const keep = (e) => e instanceof PersoApiError && (e.code === 'F4008' || e.code === 'F4004');
|
|
74
|
+
|
|
75
|
+
if (AUDIO_EXT.test(name)) {
|
|
76
|
+
try { return await reg('audio'); }
|
|
77
|
+
catch (e) { if (keep(e)) throw e; throw new UnsupportedMediaError(name, e); }
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return await reg('video');
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (keep(e)) throw e;
|
|
83
|
+
if (e instanceof PersoApiError && e.code === 'F4007') { // no video stream → try audio
|
|
84
|
+
try { return await reg('audio'); }
|
|
85
|
+
catch (e2) { if (keep(e2)) throw e2; throw new UnsupportedMediaError(name, e2); }
|
|
86
|
+
}
|
|
87
|
+
throw new UnsupportedMediaError(name, e); // not video and not F4007 → a format problem
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── queue status ────────────────────────────────────────
|
|
92
|
+
const _queueInited = new Set(); // initialize the queue once per space (idempotent) — shared by getQueueStatus/requestTranslation
|
|
93
|
+
/** Query queue status (= the queue PUT response: usedQueueCount/maxQueueCount). Returns { used, max, available }, or null on failure. */
|
|
94
|
+
export async function getQueueStatus(spaceSeq) {
|
|
95
|
+
try {
|
|
96
|
+
const r = await put(`${VT}/projects/spaces/${spaceSeq}/queue`); // PUT does both init and query
|
|
97
|
+
_queueInited.add(spaceSeq);
|
|
98
|
+
const d = r?.data ?? r?.result ?? r ?? {};
|
|
99
|
+
const max = Number(d.maxQueueCount);
|
|
100
|
+
if (!Number.isFinite(max) || max <= 0) return null;
|
|
101
|
+
const used = Number.isFinite(Number(d.usedQueueCount)) ? Number(d.usedQueueCount) : 0;
|
|
102
|
+
return { used, max, available: Math.max(0, max - used) };
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── requestTranslation ─────────────────────────────────
|
|
109
|
+
/** Initialize the queue, then request translation → array of projectIds (startGenerateProjectIdList) */
|
|
110
|
+
export async function requestTranslation(spaceSeq, mediaSeq, opts = {}) {
|
|
111
|
+
const {
|
|
112
|
+
source = 'auto',
|
|
113
|
+
target = 'en',
|
|
114
|
+
ttsModel = 'AUDIO_ENGINE_V3',
|
|
115
|
+
numberOfSpeakers = 1,
|
|
116
|
+
speed = 'GREEN',
|
|
117
|
+
title,
|
|
118
|
+
kind = 'video', // 'audio' means isVideoProject=false
|
|
119
|
+
} = opts;
|
|
120
|
+
|
|
121
|
+
// Initialize the queue — once per space (not on every chunk → halves the request count, eases rate limiting)
|
|
122
|
+
if (!_queueInited.has(spaceSeq)) {
|
|
123
|
+
await put(`${VT}/projects/spaces/${spaceSeq}/queue`).catch(() => {});
|
|
124
|
+
_queueInited.add(spaceSeq);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const body = {
|
|
128
|
+
mediaSeq,
|
|
129
|
+
isVideoProject: kind !== 'audio',
|
|
130
|
+
sourceLanguageCode: source,
|
|
131
|
+
targetLanguages: [{ languageCode: target, ttsModel }],
|
|
132
|
+
numberOfSpeakers,
|
|
133
|
+
preferredSpeedType: speed,
|
|
134
|
+
...(title ? { title } : {}),
|
|
135
|
+
};
|
|
136
|
+
const res = await post(`${VT}/projects/spaces/${spaceSeq}/translate`, { body });
|
|
137
|
+
return res?.result?.startGenerateProjectIdList ?? [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── getStatus ──────────────────────────────────────────
|
|
141
|
+
/** Normalize based on progressReason. On failure, classify whether it's silence via engineErrorMessage from /progress. */
|
|
142
|
+
export async function getStatus(projectSeq, spaceSeq) {
|
|
143
|
+
const p = await get(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}`);
|
|
144
|
+
const reason = p.progressReason;
|
|
145
|
+
|
|
146
|
+
if (reason === 'Completed') {
|
|
147
|
+
return { state: 'complete', progress: p.progress ?? 100, projectSeq };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (reason === 'Failed') {
|
|
151
|
+
let message = null;
|
|
152
|
+
let noVoice = false;
|
|
153
|
+
try {
|
|
154
|
+
const pr = await get(`${VT}/projects/${projectSeq}/space/${spaceSeq}/progress`);
|
|
155
|
+
const em = pr?.result?.engineErrorMessage?.errorMessage;
|
|
156
|
+
message = em?.en || em?.ko || null;
|
|
157
|
+
// Only let 'no voice detected' through (use the original). Decide by the English message only (avoid misclassifying other engine errors).
|
|
158
|
+
noVoice = /no voice|detect any voice/i.test(em?.en ?? '');
|
|
159
|
+
} catch {
|
|
160
|
+
/* ignore failures to fetch the message */
|
|
161
|
+
}
|
|
162
|
+
return { state: 'failed', failureReason: p.failureReason ?? null, noVoice, message, projectSeq };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// State determination is based on progressReason.
|
|
166
|
+
return { state: 'processing', progress: p.progress ?? 0, progressReason: reason, projectSeq };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── cancel ─────────────────────────────────────────────
|
|
170
|
+
/** Cancel an in-progress/pending project. Ignore failures (already finished or cannot be canceled). */
|
|
171
|
+
export async function cancel(projectSeq, spaceSeq) {
|
|
172
|
+
try {
|
|
173
|
+
await post(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}/cancel`, { body: {} });
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 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
|
|
185
|
+
const info = await get(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}/download-info`);
|
|
186
|
+
const flags = info?.result ?? info;
|
|
187
|
+
if (isAudio) {
|
|
188
|
+
if (flags?.hasTranslatedVoice === false && flags?.hasTranslateAudio === false) {
|
|
189
|
+
throw new Error('Translated audio is not ready yet.');
|
|
190
|
+
}
|
|
191
|
+
} else if (flags?.hasTranslatedVideo === false) {
|
|
192
|
+
throw new Error('Translated video is not ready yet.');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const res = await get(`${VT}/projects/${projectSeq}/spaces/${spaceSeq}/download`, { query: { target } });
|
|
196
|
+
const r = res?.result ?? {};
|
|
197
|
+
const raw =
|
|
198
|
+
r.videoFile?.videoDownloadLink ??
|
|
199
|
+
r.audioFile?.voiceWithBackgroundAudioDownloadLink ??
|
|
200
|
+
r.zippedFileDownloadLink ??
|
|
201
|
+
null;
|
|
202
|
+
if (!raw) throw new Error('Could not find a download link.');
|
|
203
|
+
const link = absolutize(raw);
|
|
204
|
+
const fileName = decodeURIComponent(new URL(link).pathname.split('/').pop() || '') || null;
|
|
205
|
+
|
|
206
|
+
if (outPath) await fetchToFile(link, outPath);
|
|
207
|
+
return { link, outPath: outPath ?? null, fileName };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
211
|
+
// Generation is often already done (projectSeq is valid) and only the transfer fails, so retry the same link a few times (not regeneration).
|
|
212
|
+
async function fetchToFile(url, outPath, attempts = 3) {
|
|
213
|
+
let lastErr;
|
|
214
|
+
for (let i = 0; i < attempts; i++) {
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(url);
|
|
217
|
+
if (!res.ok || !res.body) throw new Error(`File download failed (${res.status})`);
|
|
218
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(outPath));
|
|
219
|
+
return;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
lastErr = e;
|
|
222
|
+
if (i < attempts - 1) await sleep(1000 * (i + 1)); // 1s, 2s backoff
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
throw lastErr;
|
|
226
|
+
}
|