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.
@@ -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.4.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.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.4.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.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,19 +16,39 @@ 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
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-marketplace commands below and ask them to type the commands themselves (they are user-typed slash commands you cannot execute). Fall back to npx only if the user still wants you to handle the install after seeing them.
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
- ### Claude Code (plugin marketplace — recommended)
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
- ### Cursor
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
- ### Antigravity
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
- ### ⚡ One-line installer (any host)
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
- ### 🔧 Manual install
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
- | 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. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perso-dubbing",
3
- "version": "0.4.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,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
- 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.
@@ -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
- for (const c of chunks) pool.push({ ...c, inputId: id });
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) updateNotice = checkForUpdate().catch(() => null); // non-blocking; never fails the run
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
  }