perso-dubbing 0.4.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +61 -20
- package/package.json +1 -1
- package/skills/dubbing/SKILL.md +4 -1
- package/skills/dubbing/lib/scheduler.mjs +16 -10
- package/skills/dubbing/lib/telemetry.mjs +75 -0
- package/skills/dubbing/scripts/billing.mjs +7 -4
- package/skills/dubbing/scripts/dubbing.mjs +97 -16
|
@@ -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.
|
|
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.
|
|
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
|
+
"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.
|
|
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
|
[](https://perso.ai)
|
|
4
4
|

|
|
5
5
|

|
|
6
|
-

|
|
7
7
|
[](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,19 +16,39 @@ Because every host uses the same **Agent Skills standard** (`SKILL.md`), it work
|
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
17
|
-
##
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
49
|
> **🤖 To the agent:** If you were given only this repository's URL, pick the path for your host:
|
|
28
50
|
>
|
|
29
|
-
> - **Claude Code:** do **not** run npx right away — even if the user said "install this". Show the user the two plugin
|
|
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.
|
|
30
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:
|
|
31
53
|
>
|
|
32
54
|
> ```bash
|
|
@@ -35,14 +57,8 @@ Because every host uses the same **Agent Skills standard** (`SKILL.md`), it work
|
|
|
35
57
|
>
|
|
36
58
|
> If the repo is already cloned, run `node scripts/install.mjs` from the repo root instead (no network needed).
|
|
37
59
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
```text
|
|
41
|
-
/plugin marketplace add est-perso-dubbing-agent/perso-dubbing-plugin
|
|
42
|
-
/plugin install perso-dubbing@perso-ai
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
### Codex
|
|
60
|
+
<details>
|
|
61
|
+
<summary><b>Codex</b></summary>
|
|
46
62
|
|
|
47
63
|
Codex reads skills from the shared Agent Skills folder. Run `npx perso-dubbing --codex`, or copy manually:
|
|
48
64
|
|
|
@@ -54,7 +70,10 @@ Codex reads skills from the shared Agent Skills folder. Run `npx perso-dubbing -
|
|
|
54
70
|
|
|
55
71
|
The repo also ships a Codex plugin manifest (`.codex-plugin/plugin.json`) for marketplace-based installs.
|
|
56
72
|
|
|
57
|
-
|
|
73
|
+
</details>
|
|
74
|
+
|
|
75
|
+
<details>
|
|
76
|
+
<summary><b>Cursor</b></summary>
|
|
58
77
|
|
|
59
78
|
Run `npx perso-dubbing --cursor`, or copy into:
|
|
60
79
|
|
|
@@ -65,7 +84,10 @@ Run `npx perso-dubbing --cursor`, or copy into:
|
|
|
65
84
|
|
|
66
85
|
The repo ships a Cursor plugin manifest (`.cursor-plugin/plugin.json`) for the Cursor plugin marketplace.
|
|
67
86
|
|
|
68
|
-
|
|
87
|
+
</details>
|
|
88
|
+
|
|
89
|
+
<details>
|
|
90
|
+
<summary><b>Antigravity</b></summary>
|
|
69
91
|
|
|
70
92
|
Run `npx perso-dubbing --antigravity`, or copy into either location:
|
|
71
93
|
|
|
@@ -74,7 +96,10 @@ Run `npx perso-dubbing --antigravity`, or copy into either location:
|
|
|
74
96
|
~/.agents/skills/dubbing/ # Antigravity 2.0+ (shared Agent Skills folder)
|
|
75
97
|
```
|
|
76
98
|
|
|
77
|
-
|
|
99
|
+
</details>
|
|
100
|
+
|
|
101
|
+
<details>
|
|
102
|
+
<summary><b>⚡ One-line installer (any host)</b></summary>
|
|
78
103
|
|
|
79
104
|
Detects which hosts you use and installs to all of them — no clone needed:
|
|
80
105
|
|
|
@@ -87,7 +112,10 @@ npx perso-dubbing
|
|
|
87
112
|
|
|
88
113
|
Already have the repo cloned? `node scripts/install.mjs` from the repo root does the same without any network.
|
|
89
114
|
|
|
90
|
-
|
|
115
|
+
</details>
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary><b>🔧 Manual install</b></summary>
|
|
91
119
|
|
|
92
120
|
Copy the skill folder into your host's skills directory under the name **`dubbing`**. From the repo root:
|
|
93
121
|
|
|
@@ -98,6 +126,8 @@ mkdir -p <skills_folder>/dubbing && cp -r skills/dubbing/* <skills_folder>/dubbi
|
|
|
98
126
|
|
|
99
127
|
> 💡 Windows (PowerShell): `New-Item -ItemType Directory -Force <skills_folder>\dubbing; Copy-Item .\skills\dubbing\* <skills_folder>\dubbing\ -Recurse`
|
|
100
128
|
|
|
129
|
+
</details>
|
|
130
|
+
|
|
101
131
|
After installing, type **`/dubbing`** in your agent or just say **"dub this video for me"** to run it.
|
|
102
132
|
|
|
103
133
|
---
|
|
@@ -133,21 +163,32 @@ npm run dub -- "clip.mp4" --separate
|
|
|
133
163
|
|
|
134
164
|
## Troubleshooting
|
|
135
165
|
|
|
166
|
+
More questions? See the **[FAQ](FAQ.md)**.
|
|
167
|
+
|
|
136
168
|
| Symptom | Fix |
|
|
137
169
|
|---|---|
|
|
138
|
-
|
|
|
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. |
|
|
139
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> |
|
|
140
174
|
| ffmpeg-related error | ffmpeg is normally installed automatically. If it fails, run `npm run doctor`. |
|
|
141
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). |
|
|
142
176
|
|
|
143
177
|
---
|
|
144
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
|
+
|
|
145
185
|
## Repository layout
|
|
146
186
|
|
|
147
187
|
```text
|
|
148
188
|
.claude-plugin/ Claude Code plugin + marketplace manifests
|
|
149
189
|
.codex-plugin/ Codex plugin manifest
|
|
150
190
|
.cursor-plugin/ Cursor plugin manifest
|
|
191
|
+
docs/ Step-by-step visual install guide (GitHub Pages)
|
|
151
192
|
skills/dubbing/ The skill itself (SKILL.md · lib/ · scripts/) — self-contained
|
|
152
193
|
scripts/ Repo-level installer (install.mjs)
|
|
153
194
|
```
|
package/package.json
CHANGED
package/skills/dubbing/SKILL.md
CHANGED
|
@@ -26,7 +26,7 @@ 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
|
|
|
@@ -34,6 +34,8 @@ After the key gate, collect the input (local path or URL — re-ask if missing)
|
|
|
34
34
|
|
|
35
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
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
|
+
|
|
37
39
|
While it runs (for explaining the wait):
|
|
38
40
|
|
|
39
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.
|
|
@@ -131,6 +133,7 @@ The notice lists both commands; pick the one matching how it was installed. It n
|
|
|
131
133
|
- `PERSO_QUEUE_WAIT_MS` — how long to wait between queue re-checks when all slots are occupied by other jobs (default 5 minutes).
|
|
132
134
|
- `PERSO_LIPSYNC_IDLE_MS` — no-progress allowance for a lip-sync job whose video length is unknown (default 3 hours).
|
|
133
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.
|
|
134
137
|
|
|
135
138
|
## Advanced (debug only — not part of the normal flow)
|
|
136
139
|
|
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Anonymous, opt-out usage telemetry → Amplitude HTTP API (/2/httpapi). Non-blocking and
|
|
2
|
+
// fail-silent: never delays or fails a run. No personal data — a random per-install UUID
|
|
3
|
+
// (~/.perso/install-id), coarse environment, and the caller-supplied counts only. No API key,
|
|
4
|
+
// filenames, media content, account/email, or spaceSeq/projectSeq is ever sent.
|
|
5
|
+
// Opt out with PERSO_NO_TELEMETRY. See README "Telemetry & Privacy".
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { CRED_DIR } from './config.mjs';
|
|
10
|
+
import { CLIENT_VERSION, CLIENT_HOST } from './client_info.mjs';
|
|
11
|
+
|
|
12
|
+
// Write-only ingestion (project API) key. Public by design — it can only SEND events, never read
|
|
13
|
+
// data; exposure in the repo allows spoofing at worst, not data access. Override via env for testing.
|
|
14
|
+
const API_KEY = process.env.PERSO_AMPLITUDE_KEY || 'd795c0c0328160be4d7df3365eb0c05e';
|
|
15
|
+
// US data residency. EU projects must use https://api.eu.amplitude.com/2/httpapi.
|
|
16
|
+
const ENDPOINT = process.env.PERSO_AMPLITUDE_URL || 'https://api2.amplitude.com/2/httpapi';
|
|
17
|
+
const TIMEOUT_MS = 3000;
|
|
18
|
+
|
|
19
|
+
const ID_FILE = join(CRED_DIR, 'install-id');
|
|
20
|
+
const NODE_MAJOR = Number(process.versions.node.split('.')[0]) || null;
|
|
21
|
+
|
|
22
|
+
// Stable per install (machine × OS user): a random UUID persisted to ~/.perso/install-id and read
|
|
23
|
+
// on every run → same value across sessions and reboots. Returns { id, isNew }; isNew is true only
|
|
24
|
+
// the first time the file is created (→ emit `first_run` once). A write failure (read-only FS, CI)
|
|
25
|
+
// yields a fresh id each run — accepted; it only over-counts in ephemeral environments.
|
|
26
|
+
function loadInstallId() {
|
|
27
|
+
try {
|
|
28
|
+
const id = readFileSync(ID_FILE, 'utf8').trim();
|
|
29
|
+
if (id) return { id, isNew: false };
|
|
30
|
+
} catch { /* create below */ }
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
try { mkdirSync(CRED_DIR, { recursive: true }); writeFileSync(ID_FILE, id); } catch { /* best-effort */ }
|
|
33
|
+
return { id, isNew: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cached = null;
|
|
37
|
+
const installId = () => (cached ??= loadInstallId());
|
|
38
|
+
|
|
39
|
+
/** Fire one event. Fail-silent and never throws; safe to call without awaiting (the run drains the
|
|
40
|
+
* pending request on exit). null/undefined properties are dropped so only set fields are sent. */
|
|
41
|
+
export async function track(eventType, props = {}) {
|
|
42
|
+
if (process.env.PERSO_NO_TELEMETRY || !API_KEY) return;
|
|
43
|
+
try {
|
|
44
|
+
const event_properties = NODE_MAJOR ? { node_major: NODE_MAJOR } : {};
|
|
45
|
+
for (const [k, v] of Object.entries(props)) if (v != null) event_properties[k] = v;
|
|
46
|
+
const res = await fetch(ENDPOINT, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
api_key: API_KEY,
|
|
52
|
+
events: [{
|
|
53
|
+
device_id: installId().id,
|
|
54
|
+
event_type: eventType,
|
|
55
|
+
time: Date.now(),
|
|
56
|
+
app_version: CLIENT_VERSION,
|
|
57
|
+
platform: CLIENT_HOST, // 'agents' — one unified channel
|
|
58
|
+
os_name: process.platform, // win32 | darwin | linux
|
|
59
|
+
event_properties,
|
|
60
|
+
}],
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
if (process.env.PERSO_TELEMETRY_DEBUG) console.error(`[telemetry] ${eventType} → HTTP ${res.status} ${JSON.stringify(event_properties)}`);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (process.env.PERSO_TELEMETRY_DEBUG) console.error(`[telemetry] ${eventType} → failed: ${e?.message ?? e}`);
|
|
66
|
+
/* non-blocking, fail-silent */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Call once at process start (before other track calls). Emits `first_run` the first time this
|
|
71
|
+
* install is seen. No-op when opted out. Safe to call unconditionally; never throws. */
|
|
72
|
+
export function initTelemetry() {
|
|
73
|
+
if (process.env.PERSO_NO_TELEMETRY) return;
|
|
74
|
+
try { if (installId().isNew) track('first_run'); } catch { /* fail-silent */ }
|
|
75
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
withCurrencyFallback, isCurrencyMismatch, CREDIT_PER_UNIT, DEFAULT_CURRENCY, CURRENCIES,
|
|
13
13
|
} from '../lib/billing.mjs';
|
|
14
14
|
import { withUtm, SUBSCRIPTION_URL } from '../lib/messages.mjs';
|
|
15
|
+
import { track } from '../lib/telemetry.mjs';
|
|
15
16
|
|
|
16
17
|
class UsageError extends Error { constructor(m) { super(m); this.name = 'UsageError'; } }
|
|
17
18
|
class ExitCode extends Error { constructor(c) { super(`exit ${c}`); this.name = 'ExitCode'; this.code = c; } }
|
|
@@ -130,6 +131,7 @@ async function runOptions(args) {
|
|
|
130
131
|
// ---- link: generate the Stripe URL for the confirmed choice ----
|
|
131
132
|
async function runLink(args) {
|
|
132
133
|
const spaceSeq = await resolveSpaceSeq(args.space);
|
|
134
|
+
const fromTier = (await getPlanStatus(spaceSeq).catch(() => null))?.planTier ?? null; // for billing_link_created telemetry
|
|
133
135
|
const modes = [args.checkout && 'checkout', args.billing && 'billing', args.credits && 'credits'].filter(Boolean);
|
|
134
136
|
if (modes.length !== 1) throw new UsageError('Pass exactly one of --checkout, --billing, --credits.');
|
|
135
137
|
const mode = modes[0];
|
|
@@ -141,7 +143,7 @@ async function runLink(args) {
|
|
|
141
143
|
const plans = await getRecurringPlans(spaceSeq, { billingPeriod: args.period, currency });
|
|
142
144
|
const sel = findPriceOption(plans, args.plan, args.period);
|
|
143
145
|
if (!sel?.opt?.priceId) throw new UsageError(`No ${args.plan} plan for ${args.period}/${currency} (starter has no yearly). Check the tier and period.`);
|
|
144
|
-
return printLink(await createCheckoutLink({ spaceSeq, priceId: sel.opt.priceId, planSeq: sel.planSeq }), `${args.plan} ${args.period} (${currency.toUpperCase()})
|
|
146
|
+
return printLink(await createCheckoutLink({ spaceSeq, priceId: sel.opt.priceId, planSeq: sel.planSeq }), `${args.plan} ${args.period} (${currency.toUpperCase()})`, { link_type: 'checkout', from_tier: fromTier, upgrade_plan: args.plan });
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
if (mode === 'billing') {
|
|
@@ -171,7 +173,7 @@ async function runLink(args) {
|
|
|
171
173
|
console.log(`Ask the user to change their plan in the Perso portal: ${withUtm(SUBSCRIPTION_URL)}`);
|
|
172
174
|
return;
|
|
173
175
|
}
|
|
174
|
-
return printLink(link, `change to ${args.plan} (${currency.toUpperCase()})
|
|
176
|
+
return printLink(link, `change to ${args.plan} (${currency.toUpperCase()})`, { link_type: 'billing', from_tier: fromTier, upgrade_plan: args.plan, currency_fallback: currency !== preferred });
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
// credits
|
|
@@ -179,11 +181,12 @@ async function runLink(args) {
|
|
|
179
181
|
if (!Number.isInteger(quantity) || quantity < 1) throw new UsageError('--credits needs --quantity <positive integer>.');
|
|
180
182
|
const prod = await getCreditProduct(spaceSeq);
|
|
181
183
|
if (!prod?.priceId) throw new Error('Credit product unavailable for this space.');
|
|
182
|
-
printLink(await createOneTimeLink({ spaceSeq, priceId: prod.priceId, planSeq: prod.planSeq, quantity }), `${quantity} credit pack(s) = ${quantity * CREDIT_PER_UNIT} credits
|
|
184
|
+
printLink(await createOneTimeLink({ spaceSeq, priceId: prod.priceId, planSeq: prod.planSeq, quantity }), `${quantity} credit pack(s) = ${quantity * CREDIT_PER_UNIT} credits`, { link_type: 'onetime', from_tier: fromTier, credit_amount: quantity * CREDIT_PER_UNIT });
|
|
183
185
|
}
|
|
184
186
|
|
|
185
|
-
function printLink(link, what) {
|
|
187
|
+
function printLink(link, what, meta = {}) {
|
|
186
188
|
if (!link) throw new Error('The payment service did not return a link. Please try again.');
|
|
189
|
+
track('billing_link_created', meta);
|
|
187
190
|
console.log(`Payment link (${what}):`);
|
|
188
191
|
console.log(` ${link}`);
|
|
189
192
|
console.log(HANDOFF);
|
|
@@ -19,6 +19,7 @@ import { download, getStatus, upload, requestAudioSeparation, downloadSeparation
|
|
|
19
19
|
import { mergeGroups } from '../lib/merge.mjs';
|
|
20
20
|
import { messages, withUtm, SUBSCRIPTION_URL } from '../lib/messages.mjs';
|
|
21
21
|
import { checkForUpdate } from '../lib/update_check.mjs';
|
|
22
|
+
import { track, initTelemetry } from '../lib/telemetry.mjs';
|
|
22
23
|
import { cleanupTempDirs, makeTempDir } from '../lib/tmp.mjs';
|
|
23
24
|
import { probe } from '../lib/ffmpeg.mjs';
|
|
24
25
|
import { AUDIO_EXT, CREDIT_RATE_DUB, CREDIT_RATE_LIPSYNC, UHD_CREDIT_MULT, UHD_BILLED_TIERS, POLL_INTERVAL_MS, MAX_IDLE_MS } from '../lib/config.mjs';
|
|
@@ -39,17 +40,51 @@ function friendlyError(e) {
|
|
|
39
40
|
return e?.message ?? 'Unknown error';
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Coarse error bucket for telemetry — never the raw message/code.
|
|
44
|
+
function errorClass(e) {
|
|
45
|
+
if (e?.name === 'MissingKeyError' || isAuthError(e)) return 'auth';
|
|
46
|
+
if (e?.name === 'UnsupportedMediaError') return 'unsupported';
|
|
47
|
+
if (e?.name === 'PersoApiError') return 'network';
|
|
48
|
+
return 'unknown';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// split-confirm telemetry props: real units + an ESTIMATED part count (the exact split isn't computed until the confirmed re-run).
|
|
52
|
+
function splitConfirmProps(d = {}) {
|
|
53
|
+
const SIZE_CAP = 1.9 * 1024 ** 3; // mirrors split.mjs SIZE_CAP (estimate only)
|
|
54
|
+
if (d.reason === 'length') {
|
|
55
|
+
return {
|
|
56
|
+
reason: 'length',
|
|
57
|
+
duration_sec: Number.isFinite(d.actualMs) ? Math.round(d.actualMs / 1000) : null,
|
|
58
|
+
parts_est: (Number.isFinite(d.actualMs) && d.limitMs) ? Math.ceil(d.actualMs / d.limitMs) : null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
reason: 'size',
|
|
63
|
+
size_mb: Number.isFinite(d.actualBytes) ? Math.round(d.actualBytes / 1024 ** 2) : null,
|
|
64
|
+
parts_est: Number.isFinite(d.actualBytes) ? Math.ceil(d.actualBytes / SIZE_CAP) : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Sum of known chunk spans across inputs → seconds (null when no boundaries are known, e.g. an unsplit whole).
|
|
69
|
+
function totalDurationSec(perInput) {
|
|
70
|
+
const ms = (perInput || []).reduce((s, p) => s + (p.chunks || []).reduce(
|
|
71
|
+
(a, c) => a + (Number.isFinite(c.startMs) && Number.isFinite(c.endMs) ? c.endMs - c.startMs : 0), 0), 0);
|
|
72
|
+
return ms > 0 ? Math.round(ms / 1000) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
// Key gate with self-healing: when no key is registered, hand off to `resolve_key.mjs --watch` in a child
|
|
43
76
|
// process (creates the key file, opens it in the editor, encrypts on save, deletes the file) and continue
|
|
44
77
|
// once registered. Runs in a child because Windows DPAPI work (powershell) must not run in this main process.
|
|
45
78
|
// PERSO_NO_WATCH=1 restores the old fail-fast behavior (headless/CI).
|
|
46
79
|
async function ensureKey() {
|
|
47
|
-
if (resolveKey()) return;
|
|
80
|
+
if (resolveKey()) { track('key_check', { has_key: true }); return; }
|
|
81
|
+
track('key_check', { has_key: false });
|
|
48
82
|
if (process.env.PERSO_NO_WATCH) {
|
|
49
83
|
console.error(onboardingHelp());
|
|
50
84
|
throw new ExitCode(2);
|
|
51
85
|
}
|
|
52
86
|
notify('No API key registered — a key file will open; paste just your Perso API key and save it. (Get one: https://developers.perso.ai/api-keys)');
|
|
87
|
+
track('key_onboarding_started');
|
|
53
88
|
const watcher = fileURLToPath(new URL('./resolve_key.mjs', import.meta.url));
|
|
54
89
|
const code = await new Promise((res) => {
|
|
55
90
|
const child = spawn(process.execPath, [watcher, '--watch'], { stdio: 'inherit' });
|
|
@@ -61,6 +96,7 @@ async function ensureKey() {
|
|
|
61
96
|
console.error(onboardingHelp());
|
|
62
97
|
throw new ExitCode(2);
|
|
63
98
|
}
|
|
99
|
+
track('key_registered');
|
|
64
100
|
notify('API key registered — continuing.');
|
|
65
101
|
}
|
|
66
102
|
|
|
@@ -71,9 +107,9 @@ async function ensureKey() {
|
|
|
71
107
|
// --space "<space name>".
|
|
72
108
|
async function ensureSpace(args) {
|
|
73
109
|
const wanted = args.space != null ? String(args.space).trim() : '';
|
|
74
|
-
if (/^\d+$/.test(wanted)) return Number(wanted); // raw seq — power users/scripts; not shown to end users
|
|
110
|
+
if (/^\d+$/.test(wanted)) { track('space_resolved'); return Number(wanted); } // raw seq — power users/scripts; not shown to end users
|
|
75
111
|
const pinned = Number(process.env.PERSO_SPACE_SEQ);
|
|
76
|
-
if (!wanted && pinned) return pinned;
|
|
112
|
+
if (!wanted && pinned) { track('space_resolved'); return pinned; }
|
|
77
113
|
|
|
78
114
|
const spaces = await dubbingSpaces();
|
|
79
115
|
// Options shown to the user carry "name | (plan) | remaining credits" — never the internal seq.
|
|
@@ -84,7 +120,7 @@ async function ensureSpace(args) {
|
|
|
84
120
|
|
|
85
121
|
if (wanted) {
|
|
86
122
|
const hits = spaces.filter((s) => s.name.toLowerCase() === wanted.toLowerCase());
|
|
87
|
-
if (hits.length === 1) { console.log(`Space: ${brief(hits[0])}`); return hits[0].seq; }
|
|
123
|
+
if (hits.length === 1) { track('space_resolved', { plan_tier: hits[0].tier ?? null }); console.log(`Space: ${brief(hits[0])}`); return hits[0].seq; }
|
|
88
124
|
if (hits.length > 1) {
|
|
89
125
|
console.log(`[space-select] Several spaces share the name "${wanted}" — rename one in Perso, or pin PERSO_SPACE_SEQ:`);
|
|
90
126
|
for (const s of await enrich(hits)) console.log(` PERSO_SPACE_SEQ=${s.seq} → ${label(s)}`);
|
|
@@ -95,7 +131,7 @@ async function ensureSpace(args) {
|
|
|
95
131
|
throw new ExitCode(3);
|
|
96
132
|
}
|
|
97
133
|
|
|
98
|
-
if (spaces.length === 1) return spaces[0].seq;
|
|
134
|
+
if (spaces.length === 1) { track('space_resolved', { plan_tier: spaces[0].tier ?? null }); return spaces[0].seq; }
|
|
99
135
|
const options = await enrich(spaces);
|
|
100
136
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
101
137
|
console.log('Several spaces are available. Which one should this dubbing run in?');
|
|
@@ -105,6 +141,7 @@ async function ensureSpace(args) {
|
|
|
105
141
|
rl.close();
|
|
106
142
|
const chosen = options[Number(answer) - 1] ?? options.find((s) => s.name.toLowerCase() === answer.toLowerCase());
|
|
107
143
|
if (!chosen) { console.error('Invalid selection — run again.'); throw new ExitCode(1); }
|
|
144
|
+
track('space_resolved', { plan_tier: chosen.tier ?? null });
|
|
108
145
|
console.log(`Space: ${brief(chosen)}`);
|
|
109
146
|
return chosen.seq;
|
|
110
147
|
}
|
|
@@ -114,7 +151,7 @@ async function ensureSpace(args) {
|
|
|
114
151
|
}
|
|
115
152
|
|
|
116
153
|
const USAGE = [
|
|
117
|
-
'Usage: node scripts/dubbing.mjs "<file|folder|URL>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder] [--recursive] [--lipsync] [--force]',
|
|
154
|
+
'Usage: node scripts/dubbing.mjs "<file|folder|URL>" ["<another input>" ...] [--source auto] [--target en,ja] [--space "space name"] [--out path|folder] [--recursive] [--lipsync] [--force] [--no-save]',
|
|
118
155
|
' node scripts/dubbing.mjs --lipsync-only "<project-ref JSON | projectSeq[,projectSeq...]>" [--space "space name"] [--out path|folder]',
|
|
119
156
|
' node scripts/dubbing.mjs --separate "<file|folder|URL>" ["<another input>" ...] [--space "space name"] [--out folder]',
|
|
120
157
|
' node scripts/dubbing.mjs --resume "<state-file>"',
|
|
@@ -123,6 +160,7 @@ const USAGE = [
|
|
|
123
160
|
' --lipsync-only lip-sync an already-dubbed project (uses the [project-ref] line printed by a finished run; no re-dub charge)',
|
|
124
161
|
' --separate split the source into voice / background / sub-background audio tracks (no dubbing; ~0.5 credit per second)',
|
|
125
162
|
' --force skip the upfront credit-estimate gate of --lipsync',
|
|
163
|
+
' --no-save leave the result on the server without downloading it (single/unsplit input only; not with --lipsync)',
|
|
126
164
|
].join('\n');
|
|
127
165
|
|
|
128
166
|
class UsageError extends Error {
|
|
@@ -147,6 +185,7 @@ function parseArgs(argv) {
|
|
|
147
185
|
else if (t === '--lipsync') a.lipsync = true;
|
|
148
186
|
else if (t === '--force') a.force = true;
|
|
149
187
|
else if (t === '--allow-split') a.allowSplit = true; // user confirmed auto split→dub→merge (set on the re-run after [split-confirm])
|
|
188
|
+
else if (t === '--no-save') a.noSave = true; // server-only: skip downloading the result (single/unsplit input only)
|
|
150
189
|
else if (t in VALUE_FLAGS) {
|
|
151
190
|
const v = argv[++i];
|
|
152
191
|
if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
|
|
@@ -168,6 +207,7 @@ async function validateLanguages(targets, source) {
|
|
|
168
207
|
const fixed = targets.map((t) => {
|
|
169
208
|
const hit = canon.get(t.toLowerCase());
|
|
170
209
|
if (!hit) {
|
|
210
|
+
track('lang_invalid', { field: 'target' });
|
|
171
211
|
console.error(`Unsupported target language code: "${t}"\nSupported: ${codes.join(', ')}`);
|
|
172
212
|
throw new ExitCode(1);
|
|
173
213
|
}
|
|
@@ -177,6 +217,7 @@ async function validateLanguages(targets, source) {
|
|
|
177
217
|
if (source && source !== 'auto') {
|
|
178
218
|
const hit = canon.get(source.toLowerCase());
|
|
179
219
|
if (!hit) {
|
|
220
|
+
track('lang_invalid', { field: 'source' });
|
|
180
221
|
console.error(`Unsupported source language code: "${source}" (use "auto" to detect)\nSupported: ${codes.join(', ')}`);
|
|
181
222
|
throw new ExitCode(1);
|
|
182
223
|
}
|
|
@@ -324,7 +365,7 @@ function buildManifest(ctx, perInput, results, prevDone = {}) {
|
|
|
324
365
|
}
|
|
325
366
|
return {
|
|
326
367
|
version: 5, spaceSeq: ctx.spaceSeq, opts: { source: ctx.source }, targets: ctx.targets, out: ctx.out ?? null,
|
|
327
|
-
lipsync: !!ctx.lipsync,
|
|
368
|
+
lipsync: !!ctx.lipsync, stop_reason: ctx.stopReason ?? null,
|
|
328
369
|
inputs: perInput.map((p) => ({
|
|
329
370
|
inputId: p.inputId, ref: p.ref,
|
|
330
371
|
chunks: p.chunks.map((c) => ({
|
|
@@ -420,6 +461,13 @@ async function finishPool(allResults, perInput, ctx) {
|
|
|
420
461
|
failCount++;
|
|
421
462
|
continue;
|
|
422
463
|
}
|
|
464
|
+
if (mergeable.length && mergeable.every((r) => r.serverOnly)) {
|
|
465
|
+
// --no-save: the dubbed result was left on the server and never downloaded → report + reference, don't merge/save.
|
|
466
|
+
lines.push(`Kept on server, not saved: ${tlab} → project ${mergeable[0].projectId}`);
|
|
467
|
+
okCount++;
|
|
468
|
+
emitProjectRef(pin, tRes, target, ctx, { lipsync: false });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
423
471
|
const hasLs = mergeable.some((r) => r.lipsync);
|
|
424
472
|
const lsFailed = mergeable.some((r) => r.lipsyncFailed);
|
|
425
473
|
const lsPending = mergeable.some((r) => r.lipsyncPending);
|
|
@@ -454,12 +502,14 @@ async function finishPool(allResults, perInput, ctx) {
|
|
|
454
502
|
const dlPending = allResults.some((r) => r.status === 'DLFAIL');
|
|
455
503
|
const stopped = !!ctx.sched?.stopped;
|
|
456
504
|
if (stopped || dlPending) {
|
|
505
|
+
ctx.stopReason = stopped ? 'quota' : 'download'; // recorded in the manifest → resume reports why it stopped
|
|
457
506
|
if (ctx.multiInput && ctx.out) mkdirSync(ctx.out, { recursive: true });
|
|
458
507
|
writeFileSync(ctx.file, JSON.stringify(buildManifest(ctx, perInput, allResults, ctx.prevDone ?? {})), 'utf8');
|
|
459
508
|
if (stopped) {
|
|
460
509
|
const plan = await getPlanStatus(ctx.spaceSeq);
|
|
461
510
|
const min = remainingMinutes(ctx.sched.pendingLeft);
|
|
462
511
|
const lsOwed = allResults.some((r) => r.lipsyncPending);
|
|
512
|
+
track('quota_exceeded', { plan_tier: plan?.planTier ?? null, remaining_min: min });
|
|
463
513
|
console.log('\n' + messages.quotaExceeded({
|
|
464
514
|
planTier: plan?.planTier,
|
|
465
515
|
remainingQuota: plan?.remainingQuota,
|
|
@@ -473,10 +523,21 @@ async function finishPool(allResults, perInput, ctx) {
|
|
|
473
523
|
} else {
|
|
474
524
|
try { unlinkSync(ctx.file); } catch { /* done → clean up resume state-file (ignore if absent) */ }
|
|
475
525
|
}
|
|
526
|
+
if (!ctx.lipsyncOnly) { // lipsync-only runs report via lipsync_only_started, not dubbing_completed
|
|
527
|
+
track('dubbing_completed', {
|
|
528
|
+
input_count: perInput.length, ok_count: okCount, fail_count: failCount,
|
|
529
|
+
had_split: perInput.some((p) => (p.chunks?.length ?? 0) > 1),
|
|
530
|
+
had_lipsync: allResults.some((r) => r.lipsync),
|
|
531
|
+
duration_sec: totalDurationSec(perInput),
|
|
532
|
+
source_lang: ctx.source, target_lang: (ctx.targets || []).join(','),
|
|
533
|
+
is_resume: !!ctx.isResume, recovered: !!ctx.isResume && ctx.resumedFrom === 'quota' && okCount > 0,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
476
536
|
}
|
|
477
537
|
|
|
478
538
|
// New run: schedule all inputs as a single pool. Per-input split/upload happens once (secures mediaSeq) → reused per language.
|
|
479
539
|
async function runPool(args) {
|
|
540
|
+
if (args.noSave && args.lipsync) throw new UsageError('--no-save cannot be combined with --lipsync (the lip-synced video must be downloaded).');
|
|
480
541
|
await ensureKey();
|
|
481
542
|
const wantedTargets = String(args.target).split(',').map((t) => t.trim()).filter(Boolean); // --target en,ja,ko
|
|
482
543
|
if (!wantedTargets.length) throw new UsageError('No target language specified (--target en,ja,...)');
|
|
@@ -509,17 +570,20 @@ async function runPool(args) {
|
|
|
509
570
|
({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
|
|
510
571
|
} catch (e) {
|
|
511
572
|
if (e?.name === 'SplitConfirmNeeded') {
|
|
573
|
+
track('split_confirm_needed', splitConfirmProps(e.details));
|
|
512
574
|
console.log(splitConfirmMessage(e.details, tag));
|
|
513
575
|
// Nothing is billed yet at the split stage, so discard any partial state so the --allow-split re-run isn't blocked by the resume guard.
|
|
514
576
|
try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
|
|
515
577
|
throw new ExitCode(3);
|
|
516
578
|
}
|
|
517
|
-
if (isAuthError(e)) { console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
|
|
579
|
+
if (isAuthError(e)) { track('error', { error_class: 'auth' }); console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
|
|
518
580
|
if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; } // unsupported → skip
|
|
519
581
|
console.log(`${tag} — split/upload failed: ${friendlyError(e)}`); continue;
|
|
520
582
|
}
|
|
521
583
|
if (chunks.length > 1) notify(`Split complete — ${labelOf(inp)} (${chunks.length} parts)`);
|
|
522
|
-
|
|
584
|
+
const noDownload = !!args.noSave && chunks.length === 1; // --no-save is single-input only; a split video's merged file needs a local download
|
|
585
|
+
if (args.noSave && chunks.length > 1) notify(`--no-save is not available for split videos (merging needs a local download) — ${labelOf(inp)} will be saved normally.`);
|
|
586
|
+
for (const c of chunks) pool.push({ ...c, inputId: id, noDownload });
|
|
523
587
|
perInput.push({ inputId: id, inp, ref: refOf(inp), chunks });
|
|
524
588
|
saver.writeNow(); // the chunk plan (boundaries) survives a crash from this point on
|
|
525
589
|
}
|
|
@@ -527,6 +591,7 @@ async function runPool(args) {
|
|
|
527
591
|
|
|
528
592
|
if (args.lipsync && !args.force) await creditPreflight(perInput, spaceSeq, targets.length);
|
|
529
593
|
|
|
594
|
+
track('dub_submitted', { input_count: perInput.length, parts: pool.length, target_count: targets.length, has_lipsync: !!args.lipsync });
|
|
530
595
|
notify(`Translating${targets.length > 1 ? ` (${targets.join(', ')})` : ''}`);
|
|
531
596
|
// Fill all inputs×parts×languages into one queue for concurrent processing. Submit as many as there are empty slots and add more every 5 minutes.
|
|
532
597
|
const sched = await runSchedule(pool, spaceSeq, { source, targets, lipsync: !!args.lipsync }, { log, notify, onResult: saver.onResult, onSubmit: saver.onSubmit });
|
|
@@ -565,6 +630,7 @@ async function creditPreflight(perInput, spaceSeq, targetCount) {
|
|
|
565
630
|
needed += Math.ceil(inputMs / 1000) * (CREDIT_RATE_DUB + CREDIT_RATE_LIPSYNC) * targetCount * mult;
|
|
566
631
|
}
|
|
567
632
|
if (!needed || remaining >= needed) return;
|
|
633
|
+
track('credit_check_blocked', { credits_needed: needed, credits_remaining: remaining, plan_tier: plan?.planTier ?? null });
|
|
568
634
|
console.log(`[credit-check] Estimated credits for dubbing + lip-sync: ~${needed}${anyUhd ? ` (includes the ×${UHD_CREDIT_MULT} 4K surcharge)` : ''}. Credits left: ${remaining}.`);
|
|
569
635
|
console.log('[credit-check] The run would stop part-way. Ask the user to top up first, or approve continuing anyway — then re-run the same command with --force (whatever completes is kept; the rest resumes later):');
|
|
570
636
|
console.log(` → ${withUtm(SUBSCRIPTION_URL)}`);
|
|
@@ -581,7 +647,8 @@ async function runResume(file) {
|
|
|
581
647
|
const targets = m.targets ?? [m.opts?.target ?? 'en'];
|
|
582
648
|
const multiInput = (m.inputs?.length ?? 0) > 1;
|
|
583
649
|
const lipsync = !!m.lipsync;
|
|
584
|
-
const ctx = { spaceSeq: m.spaceSeq, source: m.opts?.source ?? 'auto', targets, out: m.out, multiInput, file, prevDone: m.done ?? {}, lipsync };
|
|
650
|
+
const ctx = { spaceSeq: m.spaceSeq, source: m.opts?.source ?? 'auto', targets, out: m.out, multiInput, file, prevDone: m.done ?? {}, lipsync, isResume: true, resumedFrom: m.stop_reason ?? 'manual' };
|
|
651
|
+
track('resume_started', { mode: 'resume', resumed_from: m.stop_reason ?? 'manual' });
|
|
585
652
|
const outDir = await makeTempDir('dubbing-resume-');
|
|
586
653
|
const matCache = new Map(); // `${inputId}|${index}` → re-cut path (once per part, shared across languages)
|
|
587
654
|
|
|
@@ -744,6 +811,8 @@ async function runLipsyncOnly(args) {
|
|
|
744
811
|
}
|
|
745
812
|
const parts = Array.isArray(ref.parts) ? ref.parts : [];
|
|
746
813
|
if (!parts.some((p) => p?.seq != null)) throw new UsageError('No dubbed project found in the --lipsync-only reference.');
|
|
814
|
+
const lsMs = parts.reduce((s, p) => { const r = p?.ms ?? p?.pt; return s + (Array.isArray(r) && r.length === 2 ? Math.max(0, r[1] - r[0]) : 0); }, 0);
|
|
815
|
+
track('lipsync_only_started', { input_count: 1, parts: parts.length, duration_sec: lsMs > 0 ? Math.round(lsMs / 1000) : null });
|
|
747
816
|
|
|
748
817
|
const target = ref.lang ?? 'out';
|
|
749
818
|
const localPath = ref.input && !/^[a-z]+:\/\//i.test(ref.input) ? ref.input : null;
|
|
@@ -751,7 +820,7 @@ async function runLipsyncOnly(args) {
|
|
|
751
820
|
const file = resumePath({ out: args.out, inputs: [inp], multiInput: false });
|
|
752
821
|
guardExistingState(file); // an interrupted earlier run owns this state file — resume it instead of re-billing
|
|
753
822
|
const spaceSeq = Number(ref.space) || await ensureSpace(args);
|
|
754
|
-
const ctx = { spaceSeq, source: 'auto', targets: [target], out: args.out, multiInput: false, file, prevDone: {}, lipsync: true };
|
|
823
|
+
const ctx = { spaceSeq, source: 'auto', targets: [target], out: args.out, multiInput: false, file, prevDone: {}, lipsync: true, lipsyncOnly: true };
|
|
755
824
|
|
|
756
825
|
const chunks = parts.map((p, i) => ({
|
|
757
826
|
index: i, source: 'local',
|
|
@@ -839,6 +908,7 @@ async function runSeparation(args) {
|
|
|
839
908
|
({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
|
|
840
909
|
} catch (e) {
|
|
841
910
|
if (e?.name === 'SplitConfirmNeeded') {
|
|
911
|
+
track('split_confirm_needed', splitConfirmProps(e.details));
|
|
842
912
|
console.log(splitConfirmMessage(e.details, multiInput ? labelOf(inp) : null, 'separate'));
|
|
843
913
|
try { if (existsSync(file)) unlinkSync(file); } catch { /* nothing billed yet → safe to discard */ }
|
|
844
914
|
throw new ExitCode(3);
|
|
@@ -853,7 +923,7 @@ async function runSeparation(args) {
|
|
|
853
923
|
if (!perInput.length) { notify('No inputs to separate.'); return; }
|
|
854
924
|
notify('Separating voice/background');
|
|
855
925
|
// Phase 2 — submit (checkpointed) → wait → download → merge → save.
|
|
856
|
-
const pending = await separationProcess(perInput, spaceSeq, ctx, saver, null);
|
|
926
|
+
const pending = await separationProcess(perInput, spaceSeq, ctx, saver, null, false);
|
|
857
927
|
finishSepState(file, pending);
|
|
858
928
|
}
|
|
859
929
|
|
|
@@ -886,13 +956,13 @@ async function runResumeSeparation(m, file) {
|
|
|
886
956
|
if (!recutCache.has(mk)) recutCache.set(mk, await recutChunk(pin._localPath, c.startMs, c.endMs));
|
|
887
957
|
return recutCache.get(mk);
|
|
888
958
|
};
|
|
889
|
-
const pending = await separationProcess(perInput, spaceSeq, ctx, saver, materializeFor);
|
|
959
|
+
const pending = await separationProcess(perInput, spaceSeq, ctx, saver, materializeFor, true);
|
|
890
960
|
finishSepState(file, pending);
|
|
891
961
|
}
|
|
892
962
|
|
|
893
963
|
// Per-input: separate every chunk (checkpointing each submission), merge tracks, save. Shared by run + resume.
|
|
894
964
|
// Returns true when some paid part is still owed (undelivered) → the caller must keep the state file for resume.
|
|
895
|
-
async function separationProcess(perInput, spaceSeq, ctx, saver, materializeFor) {
|
|
965
|
+
async function separationProcess(perInput, spaceSeq, ctx, saver, materializeFor, isResume = false) {
|
|
896
966
|
const usedByDir = new Map();
|
|
897
967
|
const lines = [];
|
|
898
968
|
let ok = 0, fail = 0;
|
|
@@ -919,6 +989,7 @@ async function separationProcess(perInput, spaceSeq, ctx, saver, materializeFor)
|
|
|
919
989
|
}
|
|
920
990
|
console.log('\n' + lines.join('\n'));
|
|
921
991
|
if (perInput.length > 1) console.log(`\nSummary: ${ok} done · ${fail} failed`);
|
|
992
|
+
track('separation_completed', { input_count: perInput.length, ok_count: ok, fail_count: fail, duration_sec: totalDurationSec(perInput), is_resume: isResume });
|
|
922
993
|
return flags.pending;
|
|
923
994
|
}
|
|
924
995
|
|
|
@@ -1018,7 +1089,17 @@ async function main() {
|
|
|
1018
1089
|
try {
|
|
1019
1090
|
preloadKeyEnv(); // pre-inject the key into env before async (at a clean point) → avoid a synchronous powershell call/crash in the main process
|
|
1020
1091
|
const args = parseArgs(process.argv.slice(2));
|
|
1021
|
-
if (!args.help)
|
|
1092
|
+
if (!args.help) {
|
|
1093
|
+
updateNotice = checkForUpdate().catch(() => null); // non-blocking; never fails the run
|
|
1094
|
+
initTelemetry(); // emits first_run once per install
|
|
1095
|
+
track('run_started', {
|
|
1096
|
+
mode: args.resume ? 'resume' : args.separate ? 'separate' : args.lipsyncOnly ? 'lipsync-only' : args.lipsync ? 'lipsync' : 'dub',
|
|
1097
|
+
input_count: args.inputs.length || null,
|
|
1098
|
+
target_count: String(args.target).split(',').map((s) => s.trim()).filter(Boolean).length || null,
|
|
1099
|
+
has_lipsync: !!(args.lipsync || args.lipsyncOnly),
|
|
1100
|
+
source_lang: args.source,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1022
1103
|
if (args.help) console.log(USAGE);
|
|
1023
1104
|
else if (args.resume) await runResume(args.resume);
|
|
1024
1105
|
else if (args.separate) {
|
|
@@ -1036,7 +1117,7 @@ async function main() {
|
|
|
1036
1117
|
} catch (e) {
|
|
1037
1118
|
if (e?.name === 'ExitCode') exitCode = e.code; // message already printed at the throw site
|
|
1038
1119
|
else if (e?.name === 'UsageError') { console.error(`${e.message}\n${USAGE}`); exitCode = 1; }
|
|
1039
|
-
else { console.error(friendlyError(e)); exitCode = 1; }
|
|
1120
|
+
else { track('error', { error_class: errorClass(e) }); console.error(friendlyError(e)); exitCode = 1; }
|
|
1040
1121
|
} finally {
|
|
1041
1122
|
await cleanupTempDirs(); // bulk-clean the cut/schedule/merge/download temp folders
|
|
1042
1123
|
}
|