perso-dubbing 0.3.0 β 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +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 +5 -2
- package/package.json +1 -1
- package/skills/dubbing/SKILL.md +36 -2
- package/skills/dubbing/lib/billing.mjs +159 -0
- package/skills/dubbing/lib/messages.mjs +8 -14
- package/skills/dubbing/lib/split.mjs +28 -7
- package/skills/dubbing/lib/update_check.mjs +83 -0
- package/skills/dubbing/scripts/billing.mjs +220 -0
- package/skills/dubbing/scripts/dubbing.mjs +205 -33
- package/skills/dubbing/scripts/probe_split.mjs +1 -1
|
@@ -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.4.0",
|
|
13
13
|
"author": { "name": "Perso AI" },
|
|
14
14
|
"homepage": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin#readme",
|
|
15
15
|
"repository": "https://github.com/est-perso-dubbing-agent/perso-dubbing-plugin",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "perso-dubbing",
|
|
3
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.4.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.4.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.4.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
|
@@ -24,13 +24,16 @@ Because every host uses the same **Agent Skills standard** (`SKILL.md`), it work
|
|
|
24
24
|
|
|
25
25
|
## Install
|
|
26
26
|
|
|
27
|
-
> **π€ To the agent:** If you were given only this repository's URL,
|
|
27
|
+
> **π€ To the agent:** If you were given only this repository's URL, pick the path for your host:
|
|
28
|
+
>
|
|
29
|
+
> - **Claude Code:** do **not** run npx right away β even if the user said "install this". Show the user the two plugin-marketplace commands below and ask them to type the commands themselves (they are user-typed slash commands you cannot execute). Fall back to npx only if the user still wants you to handle the install after seeing them.
|
|
30
|
+
> - **Codex Β· Cursor Β· Antigravity, or the user delegated the install to you:** run this single command β it fetches the package, auto-detects every installed host, and installs the skill into each:
|
|
28
31
|
>
|
|
29
32
|
> ```bash
|
|
30
33
|
> npx perso-dubbing
|
|
31
34
|
> ```
|
|
32
35
|
>
|
|
33
|
-
> If the repo is already cloned, run `node scripts/install.mjs` from the repo root instead (no network needed).
|
|
36
|
+
> If the repo is already cloned, run `node scripts/install.mjs` from the repo root instead (no network needed).
|
|
34
37
|
|
|
35
38
|
### Claude Code (plugin marketplace β recommended)
|
|
36
39
|
|
package/package.json
CHANGED
package/skills/dubbing/SKILL.md
CHANGED
|
@@ -32,6 +32,8 @@ After the key gate, collect the input (local path or URL β re-ask if missing)
|
|
|
32
32
|
|
|
33
33
|
**Space selection** β with several workspaces the worker stops before uploading, prints `[space-select]` lines (**name | (plan) | remaining credits**) and exits (code 3). Show the user ONLY those options (no internal numbers), ask which one, and re-run with `--space "<space name>"`. One space β no question; `PERSO_SPACE_SEQ` pins it.
|
|
34
34
|
|
|
35
|
+
**Split confirmation** β if the input exceeds the length or size limit and must be auto split & merged (dubbing, dub+lip-sync, or audio separation), the worker stops (exit 3) and prints `[split-confirm]` lines. Relay them and ask the user: it exceeds the length/size limit, so it needs **automatic split β process β merge**, which can come out **less polished than splitting it up themselves** β proceed automatically? On a yes, re-run the **same command with `--allow-split`** (nothing is billed until this point, so re-running is free). Batch runs: `--allow-split` authorizes every split in the run.
|
|
36
|
+
|
|
35
37
|
While it runs (for explaining the wait):
|
|
36
38
|
|
|
37
39
|
- **Split**: the whole file is uploaded first; only a plan-limit rejection installs ffmpeg and splits losslessly. External URLs (YouTubeΒ·TikTokΒ·DriveΒ·Vimeo) are handled server-side.
|
|
@@ -66,7 +68,7 @@ To split voice from background sound (no dubbing involved), run **in the backgro
|
|
|
66
68
|
- Outputs per input, next to the source (`--out` is a folder here): `<name>.voice.wav` Β· `<name>.background.wav` Β· `<name>.sub_background.wav`.
|
|
67
69
|
- Credits β seconds Γ0.5. No language options; cannot combine with lip-sync flags.
|
|
68
70
|
- Auto-split/merge, key gate, `[space-select]` and `[progress]` rules apply unchanged.
|
|
69
|
-
- **
|
|
71
|
+
- **Resume** β separation saves the same `*.dubresume.json` state with a per-part checkpoint, so an interrupted run (credits, crash, killed shell) continues with `node scripts/dubbing.mjs --resume "<state-file>"` **without re-submitting already-paid parts**. Re-running the original command is blocked (`[resume-check]`, exit 3) β run the printed `--resume` instead.
|
|
70
72
|
|
|
71
73
|
## Interruption & resume
|
|
72
74
|
|
|
@@ -80,12 +82,43 @@ Completed parts are skipped automatically; the state file is deleted when everyt
|
|
|
80
82
|
|
|
81
83
|
**Re-running the original command while a state file exists is blocked** β the worker prints `[resume-check]` lines (exit 3) instead of re-billing. Relay the printed `--resume` command and run that. Delete the state file **only** if the user explicitly chooses to pay for the completed parts again β never on your own.
|
|
82
84
|
|
|
83
|
-
**On an insufficient-credits stop**: deliver the completed parts
|
|
85
|
+
**On an insufficient-credits stop**: deliver the completed parts, then relay the worker's guidance and resume command **verbatim** β plain stdout (not `[progress]`), so don't drop it while summarizing. The guidance points to `scripts/billing.mjs` for a payment link β see **Plan upgrade & credits** below.
|
|
86
|
+
|
|
87
|
+
## Plan upgrade & credits
|
|
88
|
+
|
|
89
|
+
When the user runs out of credits (the stop above) **or** asks to upgrade / buy more credits, you can generate a Stripe payment link. **You only ever hand the link to the user β never open it or complete payment yourself**, even if the user asks you to pay.
|
|
90
|
+
|
|
91
|
+
Run this first β it detects the plan and prints the fitting flow, the choices, and (with `--shortfall`) a recommendation:
|
|
92
|
+
|
|
93
|
+
`node scripts/billing.mjs options [--shortfall <estimated remaining credits>] [--space "<space name>"]`
|
|
94
|
+
|
|
95
|
+
It routes by the current plan tier β ask only the question for that branch, then generate the link:
|
|
96
|
+
|
|
97
|
+
- **free β subscribe.** Ask which plan and **monthly or yearly** (starter is monthly-only). Currency defaults to USD; use KRW only if the user asks.
|
|
98
|
+
`node scripts/billing.mjs link --checkout --plan <starter|creator|pro> --period <monthly|yearly> [--currency usd|krw]`
|
|
99
|
+
- **starter / creator β change plan.** Ask which plan; billing period and currency are locked to the existing subscription (handled automatically).
|
|
100
|
+
`node scripts/billing.mjs link --billing --plan <creator|pro>`
|
|
101
|
+
- **pro / business β buy credits.** Ask how many packs (1 pack = 60 credits, USD). With `--shortfall`, `options` recommends a quantity.
|
|
102
|
+
`node scripts/billing.mjs link --credits --quantity <n>`
|
|
103
|
+
- **enterprise β no self-serve.** Tell the user to contact their workspace administrator.
|
|
104
|
+
|
|
105
|
+
**Recommending on a credit-out stop**: estimate the remaining work's credits (dubbing β Γ1/s Β· lip-sync β Γ2 Β· separation β Γ0.5, and Γ3 for 4K on pro+), pass it as `--shortfall`, and relay the tool's recommendation. If even the top self-serve plan or a reasonable credit quantity can't cover it, point the user to their administrator (Enterprise) instead.
|
|
106
|
+
|
|
107
|
+
Hand the returned link to the user to complete payment in their browser; after they top up, resume the interrupted job with the printed `--resume` command (no re-billing).
|
|
84
108
|
|
|
85
109
|
## Perso portal (answer only when asked)
|
|
86
110
|
|
|
87
111
|
Every run is also a project in the user's Perso portal account. If the user wants more than the delivered files (subtitles, audio-only, other formats) or to browse/re-download earlier projects, point them to https://portal.perso.ai β projects live in the workspace used for the run; split parts are numbered `_01`, `_02`, β¦. Never add this to progress relays or final reports.
|
|
88
112
|
|
|
113
|
+
## Version updates
|
|
114
|
+
|
|
115
|
+
Once a day (first run after 00:00 UTC) the worker checks npm for a newer release and, **after the current job finishes** (never mid-run), may print a one-line `βΉοΈ Update available: β¦` notice. When you see it, relay it and act by install method:
|
|
116
|
+
|
|
117
|
+
- **Claude Code plugin (marketplace):** tell the user to run `/plugin update perso-dubbing` β a slash command you cannot run yourself.
|
|
118
|
+
- **npx / manual install:** ask the user whether to update, and on yes run `npx perso-dubbing@latest`.
|
|
119
|
+
|
|
120
|
+
The notice lists both commands; pick the one matching how it was installed. It never blocks a run and can be silenced with `PERSO_NO_UPDATE_CHECK=1`.
|
|
121
|
+
|
|
89
122
|
## Config (env)
|
|
90
123
|
|
|
91
124
|
- `PERSO_API_BASE` β API base URL (default `https://api.perso.ai`). **https `perso.ai` hosts only** β anything else is rejected at startup (the API key travels in a header to this host).
|
|
@@ -97,6 +130,7 @@ Every run is also a project in the user's Perso portal account. If the user want
|
|
|
97
130
|
- `PERSO_SIZE_CAP_BYTES` β upload size cap used for the split decision (default β1.9 GB, under the API's 2 GB limit).
|
|
98
131
|
- `PERSO_QUEUE_WAIT_MS` β how long to wait between queue re-checks when all slots are occupied by other jobs (default 5 minutes).
|
|
99
132
|
- `PERSO_LIPSYNC_IDLE_MS` β no-progress allowance for a lip-sync job whose video length is unknown (default 3 hours).
|
|
133
|
+
- `PERSO_NO_UPDATE_CHECK` β skip the once-a-day npm version-update check (headless/CI, or to avoid the extra network call).
|
|
100
134
|
|
|
101
135
|
## Advanced (debug only β not part of the normal flow)
|
|
102
136
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Plan-upgrade / credit-purchase helpers. All calls go to the Perso payment service with the same
|
|
2
|
+
// XP-API-KEY the rest of the skill uses; the agent only ever receives a Stripe link to hand to the user β
|
|
3
|
+
// it must never open or complete payment itself.
|
|
4
|
+
import { get, post, PersoApiError } from './http_client.mjs';
|
|
5
|
+
|
|
6
|
+
export const CURRENCIES = ['usd', 'krw'];
|
|
7
|
+
export const DEFAULT_CURRENCY = 'usd'; // international default for new subscriptions; KRW on request
|
|
8
|
+
export const ONE_TIME_CURRENCY = 'usd'; // credit packs are always charged in USD
|
|
9
|
+
// Credits granted per pack β used only to turn a credit shortfall into a pack quantity.
|
|
10
|
+
// Server billing is authoritative.
|
|
11
|
+
export const CREDIT_PER_UNIT = 60;
|
|
12
|
+
export const RETURN_URL = 'https://perso.ai/en/workspace/vt';
|
|
13
|
+
const SUCCESS_PARAMS = 's_seq={spaceSeq}';
|
|
14
|
+
|
|
15
|
+
// Low β high. Used to find which tiers are an upgrade from the current one.
|
|
16
|
+
const TIER_ORDER = ['free', 'starter', 'creator', 'pro', 'business', 'enterprise'];
|
|
17
|
+
const tierRank = (t) => TIER_ORDER.indexOf(String(t ?? '').toLowerCase());
|
|
18
|
+
|
|
19
|
+
/** Which purchase flow fits the current plan tier. */
|
|
20
|
+
export function actionForTier(tier) {
|
|
21
|
+
const t = String(tier ?? '').toLowerCase();
|
|
22
|
+
if (t === 'starter' || t === 'creator') return 'billing'; // has a subscription β change plan
|
|
23
|
+
if (t === 'pro' || t === 'business') return 'one-time'; // top self-serve β buy credits
|
|
24
|
+
if (t === 'enterprise') return 'contact'; // custom β contact admin, no self-serve
|
|
25
|
+
return 'checkout'; // free/unknown β new subscription
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const extractLink = (res) => res?.link ?? res?.result?.link ?? null;
|
|
29
|
+
|
|
30
|
+
function normalizePlan(tier, p) {
|
|
31
|
+
return {
|
|
32
|
+
tier: p.tier ?? tier,
|
|
33
|
+
planSeq: p.planSeq,
|
|
34
|
+
planName: p.planName,
|
|
35
|
+
isCurrentPlan: !!p.isCurrentPlan,
|
|
36
|
+
isAccessible: p.isAccessible !== false,
|
|
37
|
+
isSwitchable: p.isSwitchable !== false,
|
|
38
|
+
priceOptions: (p.priceOptions ?? []).map((o) => ({
|
|
39
|
+
priceId: o.priceId,
|
|
40
|
+
price: o.price,
|
|
41
|
+
priceUnit: o.priceUnit,
|
|
42
|
+
billingPeriod: o.billingPeriod,
|
|
43
|
+
discountRate: o.discountRate ?? 0,
|
|
44
|
+
isCurrentPlan: !!o.isCurrentPlan,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Recurring subscription plans for one billing period + currency, one entry per tier. */
|
|
50
|
+
export async function getRecurringPlans(spaceSeq, { billingPeriod, currency }) {
|
|
51
|
+
const res = await get('/payment/api/v1/plan/groups', {
|
|
52
|
+
query: { spaceSeq, productType: 'recurring', billingPeriod, currency },
|
|
53
|
+
});
|
|
54
|
+
const groups = res?.result ?? {};
|
|
55
|
+
const plans = [];
|
|
56
|
+
for (const [tier, arr] of Object.entries(groups)) {
|
|
57
|
+
const p = Array.isArray(arr) ? arr[0] : null;
|
|
58
|
+
if (p) plans.push(normalizePlan(tier, p));
|
|
59
|
+
}
|
|
60
|
+
return plans;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The one-time credit pack (productType=single, USD). Quantity-priced: unitPrice per pack. */
|
|
64
|
+
export async function getCreditProduct(spaceSeq) {
|
|
65
|
+
const res = await get('/payment/api/v1/plan', {
|
|
66
|
+
query: { spaceSeq, productType: 'single', currency: ONE_TIME_CURRENCY },
|
|
67
|
+
});
|
|
68
|
+
const p = (res?.result ?? [])[0] ?? null;
|
|
69
|
+
if (!p) return null;
|
|
70
|
+
const opt = (p.priceOptions ?? [])[0] ?? null;
|
|
71
|
+
return {
|
|
72
|
+
planSeq: p.planSeq,
|
|
73
|
+
planName: p.planName,
|
|
74
|
+
minQuantity: p.purchaseSeatCountMin ?? 1,
|
|
75
|
+
maxQuantity: p.purchaseSeatCountMax ?? -1, // -1 = unlimited
|
|
76
|
+
priceId: opt?.priceId ?? null,
|
|
77
|
+
unitPrice: opt?.price ?? null,
|
|
78
|
+
priceUnit: opt?.priceUnit ?? ONE_TIME_CURRENCY,
|
|
79
|
+
creditPerUnit: CREDIT_PER_UNIT,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Tiers strictly above `currentTier` that can be switched to (for the plan list to offer). */
|
|
84
|
+
export function upgradeCandidates(plans, currentTier) {
|
|
85
|
+
const rank = tierRank(currentTier);
|
|
86
|
+
return plans.filter((p) =>
|
|
87
|
+
tierRank(p.tier) > rank && p.tier !== 'enterprise' && p.isAccessible && p.isSwitchable && !p.isCurrentPlan);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Find a tier's price option for the exact billing period (null if that tier has no option for it). */
|
|
91
|
+
export function findPriceOption(plans, tier, billingPeriod) {
|
|
92
|
+
const plan = plans.find((p) => String(p.tier).toLowerCase() === String(tier).toLowerCase());
|
|
93
|
+
if (!plan) return null;
|
|
94
|
+
const opt = plan.priceOptions.find((o) => o.billingPeriod === billingPeriod);
|
|
95
|
+
return opt ? { planSeq: plan.planSeq, plan, opt } : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The billing period of the current subscription (billing requires the target to match it). */
|
|
99
|
+
export function currentBillingPeriod(plans) {
|
|
100
|
+
for (const p of plans) {
|
|
101
|
+
const cur = p.priceOptions.find((o) => o.isCurrentPlan);
|
|
102
|
+
if (cur) return cur.billingPeriod;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Credit packs needed to cover a shortfall (each pack = CREDIT_PER_UNIT credits). */
|
|
108
|
+
export function recommendCredits(shortfall) {
|
|
109
|
+
const quantity = Math.max(1, Math.ceil(Number(shortfall) / CREDIT_PER_UNIT));
|
|
110
|
+
return { quantity, credits: quantity * CREDIT_PER_UNIT };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- link generation (each returns a Stripe URL string) ---
|
|
114
|
+
|
|
115
|
+
/** Free β new subscription. */
|
|
116
|
+
export async function createCheckoutLink({ spaceSeq, priceId, planSeq }) {
|
|
117
|
+
const res = await post('/payment/api/v1/charge/checkout', {
|
|
118
|
+
body: {
|
|
119
|
+
priceId, planSeq, spaceSeq,
|
|
120
|
+
successUrl: RETURN_URL, cancelUrl: RETURN_URL,
|
|
121
|
+
successParams: SUCCESS_PARAMS, isAddSpaceRequest: false, force: false,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return extractLink(res);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Existing subscriber β change plan (Stripe billing-portal preview; body is just these three). */
|
|
128
|
+
export async function createBillingLink({ spaceSeq, priceId, planSeq }) {
|
|
129
|
+
const res = await post('/payment/api/v1/charge/billing', { body: { priceId, planSeq, spaceSeq } });
|
|
130
|
+
return extractLink(res);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Subscriber β buy N credit packs (one-time checkout, USD). */
|
|
134
|
+
export async function createOneTimeLink({ spaceSeq, priceId, planSeq, quantity }) {
|
|
135
|
+
const res = await post('/payment/api/v1/charge/checkout/one-time', {
|
|
136
|
+
body: {
|
|
137
|
+
priceId, planSeq, quantity, spaceSeq,
|
|
138
|
+
successUrl: RETURN_URL, cancelUrl: RETURN_URL,
|
|
139
|
+
successParams: SUCCESS_PARAMS, isAddSpaceRequest: false, force: false,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return extractLink(res);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** PAY4052 = Stripe session creation failed β for an existing subscriber this usually means the priceId's
|
|
146
|
+
* currency (or period) doesn't match the live subscription, so callers try the preferred currency then
|
|
147
|
+
* fall back to the other. */
|
|
148
|
+
export const isCurrencyMismatch = (err) => err instanceof PersoApiError && err.code === 'PAY4052';
|
|
149
|
+
|
|
150
|
+
/** Run `fn(currency)` for the preferred currency, falling back to the other on a currency mismatch. */
|
|
151
|
+
export async function withCurrencyFallback(preferred, fn) {
|
|
152
|
+
const order = preferred === 'krw' ? ['krw', 'usd'] : ['usd', 'krw'];
|
|
153
|
+
let lastErr = null;
|
|
154
|
+
for (const currency of order) {
|
|
155
|
+
try { return { currency, result: await fn(currency) }; }
|
|
156
|
+
catch (e) { if (isCurrencyMismatch(e)) { lastErr = e; continue; } throw e; }
|
|
157
|
+
}
|
|
158
|
+
throw lastErr;
|
|
159
|
+
}
|
|
@@ -11,29 +11,23 @@ export const UTM_PARAMS =
|
|
|
11
11
|
`utm_source=${UTM_SOURCE}&utm_medium=agent-skill&utm_campaign=perso-dubbing&utm_content=v${CLIENT_VERSION}`;
|
|
12
12
|
export const withUtm = (url) => url + (url.includes('?') ? '&' : '?') + UTM_PARAMS;
|
|
13
13
|
|
|
14
|
-
// free/starter have no credit purchase, so only a plan upgrade is suggested.
|
|
15
|
-
const LOW_TIERS = new Set(['free', 'starter']);
|
|
16
|
-
|
|
17
14
|
export const messages = {
|
|
18
|
-
// Out-of-usage guidance.
|
|
15
|
+
// Out-of-usage guidance. The agent can generate a direct Stripe link via scripts/billing.mjs, which
|
|
16
|
+
// routes by the current plan tier (free β subscribe Β· starter/creator β change plan Β· pro/business β credits).
|
|
19
17
|
// { planTier, remainingQuota, remainingNote, resumeHint, note }
|
|
20
18
|
quotaExceeded: ({ planTier, remainingQuota, remainingNote, resumeHint, note } = {}) => {
|
|
21
|
-
const isLow = LOW_TIERS.has(String(planTier ?? '').toLowerCase());
|
|
22
19
|
const status =
|
|
23
20
|
` Current plan: ${planTier ?? 'unknown'} Β· Credits left: ${remainingQuota ?? '?'}` +
|
|
24
21
|
(remainingNote ? ` Β· Remaining: ${remainingNote}` : '');
|
|
25
|
-
|
|
22
|
+
return [
|
|
26
23
|
'Out of usage/credits β only part of the work completed. The finished items are delivered above.',
|
|
27
24
|
status,
|
|
28
25
|
...(note ? [note] : []),
|
|
29
26
|
'',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
if (resumeHint) lines.push(` Resume: ${resumeHint}`);
|
|
37
|
-
return lines.join('\n');
|
|
27
|
+
'To continue you can generate a payment link (routes by plan: subscribe / change plan / buy credits):',
|
|
28
|
+
' β node scripts/billing.mjs options (add --shortfall <estimated remaining credits> for a recommendation)',
|
|
29
|
+
' Then give the returned Stripe link to the user to complete payment in their browser β never pay on their behalf.',
|
|
30
|
+
...(resumeHint ? [` Resume after topping up: ${resumeHint}`] : []),
|
|
31
|
+
].join('\n');
|
|
38
32
|
},
|
|
39
33
|
};
|
|
@@ -17,16 +17,23 @@ const MAX_DEPTH = 4;
|
|
|
17
17
|
const SEG_MARGIN_MS = 2000; // SEG = cap β GOP β margin: a safety buffer to stay below the cap
|
|
18
18
|
const MIN_SEG_MS = 3000; // if SEG is smaller than this (GOP too large relative to the cap), lossless is impossible β re-encode fallback
|
|
19
19
|
|
|
20
|
+
/** Thrown at depth 0 when a split is required but the user hasn't authorized it yet (no allowSplit).
|
|
21
|
+
* Carries what the caller needs to build the confirmation prompt: { reason:'length'|'size', limitMs?, actualMs?, actualBytes? }. */
|
|
22
|
+
export class SplitConfirmNeeded extends Error {
|
|
23
|
+
constructor(details) { super('split confirmation required'); this.name = 'SplitConfirmNeeded'; this.details = details; }
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
/** @returns {{chunks:Array<{index,source,path?,sourceUrl?,mediaSeq?,startMs?,endMs?}>, notice:string|null}} */
|
|
21
27
|
export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
|
|
22
28
|
const log = hooks.log ?? (() => {});
|
|
23
29
|
const notify = hooks.notify ?? (() => {}); // user-facing milestones (split start, etc.)
|
|
30
|
+
const allowSplit = hooks.allowSplit ?? false; // false β stop and ask before the first split (SplitConfirmNeeded)
|
|
24
31
|
if (prepared.source === 'external') {
|
|
25
32
|
return { chunks: [{ index: 0, source: 'external', sourceUrl: prepared.sourceUrl }], notice: null };
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
const localPath = prepared.localPath ?? prepared.path;
|
|
29
|
-
const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify);
|
|
36
|
+
const leaves = await uploadOrSplit(localPath, spaceSeq, 0, 0, null, log, notify, allowSplit);
|
|
30
37
|
// carry each leaf's absolute boundaries (startMs/endMs) on the chunk β on resume, re-cut only that segment from the original.
|
|
31
38
|
// when split, number the title _01,_02β¦ β in the Perso project list, distinguish pieces of the same original (a single piece keeps the original name).
|
|
32
39
|
const baseTitle = (prepared.originalName ?? basename(localPath)).replace(/\.[^.]+$/, '');
|
|
@@ -43,12 +50,13 @@ export async function resolveChunks(prepared, spaceSeq, hooks = {}) {
|
|
|
43
50
|
* startMs/spanMs are absolute positions relative to the original (an unsplit whole leaf has endMs=null).
|
|
44
51
|
* Side optimization: a size overflow can be known in advance via local stat, so split a huge file directly instead of uploading it whole.
|
|
45
52
|
*/
|
|
46
|
-
async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}) {
|
|
53
|
+
async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = null, log = () => {}, notify = () => {}, allowSplit = true) {
|
|
47
54
|
if (depth < MAX_DEPTH) {
|
|
48
55
|
const { size } = await stat(localPath);
|
|
49
|
-
if (size > SIZE_CAP) { // size overflow β split before uploading
|
|
56
|
+
if (size > SIZE_CAP) { // size overflow β split before uploading (known locally, so no upload is wasted)
|
|
57
|
+
if (depth === 0 && !allowSplit) throw new SplitConfirmNeeded({ reason: 'size', actualBytes: size });
|
|
50
58
|
log('The file is large, so the video is processed in parts. Cutting and merging takes a little while.');
|
|
51
|
-
return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify);
|
|
59
|
+
return splitAndRecurse(localPath, spaceSeq, depth, startMs, Infinity, log, notify, allowSplit);
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
try {
|
|
@@ -59,14 +67,27 @@ async function uploadOrSplit(localPath, spaceSeq, depth, startMs = 0, spanMs = n
|
|
|
59
67
|
const code = e instanceof PersoApiError ? e.code : null;
|
|
60
68
|
if (code !== 'F4008' && code !== 'F4004') throw e; // errors other than length/size are rethrown as-is
|
|
61
69
|
if (depth >= MAX_DEPTH) throw new Error(`Split limit exceeded (depth ${depth}) β cannot upload`);
|
|
70
|
+
if (depth === 0 && !allowSplit) throw await splitConfirmFor(code, e, localPath);
|
|
62
71
|
log('The video exceeds the length/size limit, so it is processed in parts. Cutting and merging takes a little while.');
|
|
63
72
|
const lengthCapMs = code === 'F4008' ? Number(e.data?.maxLengthMs) : Infinity;
|
|
64
|
-
return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify);
|
|
73
|
+
return splitAndRecurse(localPath, spaceSeq, depth, startMs, lengthCapMs, log, notify, allowSplit);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build the confirmation error for a length (F4008) or size (F4004) rejection, best-effort filling the actual value.
|
|
78
|
+
async function splitConfirmFor(code, err, localPath) {
|
|
79
|
+
if (code === 'F4008') {
|
|
80
|
+
let actualMs = null;
|
|
81
|
+
try { ({ durationMs: actualMs } = await probe(localPath)); } catch { /* ffmpeg absent β omit the actual length */ }
|
|
82
|
+
return new SplitConfirmNeeded({ reason: 'length', limitMs: Number(err.data?.maxLengthMs) || null, actualMs });
|
|
65
83
|
}
|
|
84
|
+
let actualBytes = null;
|
|
85
|
+
try { ({ size: actualBytes } = await stat(localPath)); } catch { /* omit the actual size */ }
|
|
86
|
+
return new SplitConfirmNeeded({ reason: 'size', actualBytes });
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
/** Split localPath by the length/size limit and recursively upload each piece. baseStartMs is the absolute offset relative to the original. */
|
|
69
|
-
async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}) {
|
|
90
|
+
async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCapMs, log = () => {}, notify = () => {}, allowSplit = true) {
|
|
70
91
|
ensureFfmpeg(); // install only when cutting is confirmed
|
|
71
92
|
if (depth === 0) notify('Splitting β the video exceeds the length/size limit, so it is processed in parts.'); // user-facing
|
|
72
93
|
const { durationMs, sizeBytes } = await probe(localPath);
|
|
@@ -79,7 +100,7 @@ async function splitAndRecurse(localPath, spaceSeq, depth, baseStartMs, lengthCa
|
|
|
79
100
|
for (let i = 0; i < parts.length; i++) {
|
|
80
101
|
log(`Uploading piece ${i + 1}/${parts.length}...`);
|
|
81
102
|
const p = parts[i];
|
|
82
|
-
out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify)));
|
|
103
|
+
out.push(...(await uploadOrSplit(p.path, spaceSeq, depth + 1, baseStartMs + p.startMs, p.durationMs, log, notify, allowSplit)));
|
|
83
104
|
}
|
|
84
105
|
return out;
|
|
85
106
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Once-a-day (UTC) check against the npm registry for a newer release. Both npx and the plugin marketplace
|
|
2
|
+
// install from npm, so the registry is the single source of truth. Non-blocking, fail-silent, throttled to
|
|
3
|
+
// at most one network call per UTC day; never delays or fails a run. Opt out with PERSO_NO_UPDATE_CHECK.
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { CRED_DIR } from './config.mjs';
|
|
8
|
+
import { CLIENT_VERSION } from './client_info.mjs';
|
|
9
|
+
|
|
10
|
+
const PKG = 'perso-dubbing';
|
|
11
|
+
const REGISTRY = `https://registry.npmjs.org/${PKG}/latest`;
|
|
12
|
+
const CACHE_FILE = join(CRED_DIR, 'update-check.json');
|
|
13
|
+
const TIMEOUT_MS = 3000;
|
|
14
|
+
|
|
15
|
+
const utcDate = () => new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
|
|
16
|
+
|
|
17
|
+
function readCache() {
|
|
18
|
+
try { return JSON.parse(readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
|
|
19
|
+
}
|
|
20
|
+
function writeCache(obj) {
|
|
21
|
+
try { mkdirSync(CRED_DIR, { recursive: true }); writeFileSync(CACHE_FILE, JSON.stringify(obj)); } catch { /* ignore */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// x.y.z compare: a>b β 1, a<b β -1, equal β 0. parseInt drops any pre-release/build suffix (e.g. "3-beta" β 3).
|
|
25
|
+
function cmpSemver(a, b) {
|
|
26
|
+
const part = (s) => String(s).split('.').map((n) => parseInt(n, 10) || 0);
|
|
27
|
+
const pa = part(a), pb = part(b);
|
|
28
|
+
for (let i = 0; i < 3; i++) {
|
|
29
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
30
|
+
if (x > y) return 1;
|
|
31
|
+
if (x < y) return -1;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchLatest() {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
39
|
+
if (!res.ok) return null;
|
|
40
|
+
const j = await res.json();
|
|
41
|
+
return typeof j?.version === 'string' ? j.version : null;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Best-effort install-method guess from the running file path (marketplace copies live under a plugins dir).
|
|
46
|
+
function installMethod() {
|
|
47
|
+
try {
|
|
48
|
+
const p = fileURLToPath(import.meta.url).replace(/\\/g, '/').toLowerCase();
|
|
49
|
+
if (p.includes('/plugins/') || p.includes('/marketplace')) return 'marketplace';
|
|
50
|
+
} catch { /* fall through */ }
|
|
51
|
+
return 'npx';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildNotice(latest) {
|
|
55
|
+
const lines = [`βΉοΈ Update available: ${PKG} ${latest} (you have ${CLIENT_VERSION}).`];
|
|
56
|
+
if (installMethod() === 'marketplace') {
|
|
57
|
+
lines.push(' In Claude Code (plugin): /plugin update perso-dubbing');
|
|
58
|
+
lines.push(' Otherwise (npx / manual): npx perso-dubbing@latest');
|
|
59
|
+
} else {
|
|
60
|
+
lines.push(' Update: npx perso-dubbing@latest');
|
|
61
|
+
lines.push(' In Claude Code (plugin): /plugin update perso-dubbing');
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns a one-line update notice (string) if a newer version exists, else null. Never throws.
|
|
67
|
+
* Fetches from npm at most once per UTC day (cached); reuses the last known result otherwise. */
|
|
68
|
+
export async function checkForUpdate() {
|
|
69
|
+
if (process.env.PERSO_NO_UPDATE_CHECK) return null;
|
|
70
|
+
if (CLIENT_VERSION === '0.0.0') return null; // version unknown β can't compare reliably
|
|
71
|
+
|
|
72
|
+
const today = utcDate();
|
|
73
|
+
const cache = readCache();
|
|
74
|
+
let latest = cache.latest ?? null;
|
|
75
|
+
if (cache.date !== today) {
|
|
76
|
+
const fetched = await fetchLatest();
|
|
77
|
+
latest = fetched ?? cache.latest ?? null; // keep last known on failure
|
|
78
|
+
writeCache({ date: today, latest }); // mark checked today regardless β β€1 fetch/day
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!latest || cmpSemver(latest, CLIENT_VERSION) <= 0) return null;
|
|
82
|
+
return buildNotice(latest);
|
|
83
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Plan upgrade & credit purchase β generates a Stripe payment link for the user to open and pay themselves.
|
|
3
|
+
// The agent NEVER opens the link or pays on the user's behalf; it only relays it. Routing by current tier:
|
|
4
|
+
// free β checkout (subscribe) Β· starter/creator β billing (change plan) Β· pro/business β one-time (credits) Β· enterprise β contact
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { realpathSync } from 'node:fs';
|
|
7
|
+
import { resolveKey, onboardingHelp, preloadKeyEnv } from './resolve_key.mjs';
|
|
8
|
+
import { getPlanStatus, resolveSpace, dubbingSpaces } from '../lib/space.mjs';
|
|
9
|
+
import {
|
|
10
|
+
actionForTier, getRecurringPlans, getCreditProduct, upgradeCandidates, findPriceOption,
|
|
11
|
+
currentBillingPeriod, recommendCredits, createCheckoutLink, createBillingLink, createOneTimeLink,
|
|
12
|
+
withCurrencyFallback, isCurrencyMismatch, CREDIT_PER_UNIT, DEFAULT_CURRENCY, CURRENCIES,
|
|
13
|
+
} from '../lib/billing.mjs';
|
|
14
|
+
import { withUtm, SUBSCRIPTION_URL } from '../lib/messages.mjs';
|
|
15
|
+
|
|
16
|
+
class UsageError extends Error { constructor(m) { super(m); this.name = 'UsageError'; } }
|
|
17
|
+
class ExitCode extends Error { constructor(c) { super(`exit ${c}`); this.name = 'ExitCode'; this.code = c; } }
|
|
18
|
+
|
|
19
|
+
const USAGE = [
|
|
20
|
+
'Usage:',
|
|
21
|
+
' node scripts/billing.mjs options [--shortfall <credits>] [--space "<space name>"]',
|
|
22
|
+
' node scripts/billing.mjs link --checkout --plan <tier> --period <monthly|yearly> [--currency usd|krw] [--space "<space name>"]',
|
|
23
|
+
' node scripts/billing.mjs link --billing --plan <tier> [--space "<space name>"]',
|
|
24
|
+
' node scripts/billing.mjs link --credits --quantity <n> [--space "<space name>"]',
|
|
25
|
+
'',
|
|
26
|
+
'options detect the current plan, show the fitting purchase flow + choices, and (with --shortfall) a recommendation',
|
|
27
|
+
'link generate the Stripe payment link for the user\'s confirmed choice (hand it to the user; never pay for them)',
|
|
28
|
+
].join('\n');
|
|
29
|
+
|
|
30
|
+
const HANDOFF = 'Give this link to the user to open in their browser and complete payment. Do NOT open it or pay on their behalf.';
|
|
31
|
+
const CONTACT = 'Enterprise plans have no self-serve checkout β ask the user to contact their workspace administrator.';
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const a = { inputs: [] };
|
|
35
|
+
const VALUE = { '--plan': 'plan', '--period': 'period', '--currency': 'currency', '--quantity': 'quantity', '--space': 'space', '--shortfall': 'shortfall' };
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const t = argv[i];
|
|
38
|
+
if (t === '--help' || t === '-h') a.help = true;
|
|
39
|
+
else if (t === '--checkout') a.checkout = true;
|
|
40
|
+
else if (t === '--billing') a.billing = true;
|
|
41
|
+
else if (t === '--credits') a.credits = true;
|
|
42
|
+
else if (t in VALUE) {
|
|
43
|
+
const v = argv[++i];
|
|
44
|
+
if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
|
|
45
|
+
a[VALUE[t]] = v;
|
|
46
|
+
} else if (t.startsWith('--')) throw new UsageError(`Unknown option: ${t}`);
|
|
47
|
+
else a.inputs.push(t);
|
|
48
|
+
}
|
|
49
|
+
return a;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --space accepts a numeric spaceSeq or a space name (matching dubbing.mjs); omitted β the pinned/only space.
|
|
53
|
+
async function resolveSpaceSeq(arg) {
|
|
54
|
+
if (arg === undefined) return resolveSpace();
|
|
55
|
+
const n = Number(arg);
|
|
56
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
57
|
+
const spaces = await dubbingSpaces();
|
|
58
|
+
const hit = spaces.find((s) => s.name === arg) ?? spaces.find((s) => s.name.toLowerCase() === String(arg).toLowerCase());
|
|
59
|
+
if (!hit) throw new UsageError(`No space named "${arg}". Available: ${spaces.map((s) => s.name).join(', ')}`);
|
|
60
|
+
return hit.seq;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const money = (o) => (o && o.price != null ? `${String(o.price).trim()} ${String(o.priceUnit).toUpperCase()}` : '');
|
|
64
|
+
|
|
65
|
+
// ---- options: show the right action + choices + recommendation for the agent to relay ----
|
|
66
|
+
async function runOptions(args) {
|
|
67
|
+
const spaceSeq = await resolveSpaceSeq(args.space);
|
|
68
|
+
const plan = await getPlanStatus(spaceSeq);
|
|
69
|
+
if (!plan) throw new Error('Could not read the current plan β please try again.'); // don't mis-route a paid user to "new subscription"
|
|
70
|
+
const tier = plan.planTier ?? null;
|
|
71
|
+
const action = actionForTier(tier);
|
|
72
|
+
const shortfall = args.shortfall != null ? Number(args.shortfall) : null;
|
|
73
|
+
|
|
74
|
+
const L = [`Current plan: ${tier ?? 'unknown'} (spaceSeq ${spaceSeq}${plan?.remainingQuota != null ? `, credits left ${plan.remainingQuota}` : ''})`, `Action: ${action}`];
|
|
75
|
+
|
|
76
|
+
if (action === 'contact') { L.push(CONTACT); return void console.log(L.join('\n')); }
|
|
77
|
+
|
|
78
|
+
if (action === 'checkout') {
|
|
79
|
+
const [mo, yr] = await Promise.all([
|
|
80
|
+
getRecurringPlans(spaceSeq, { billingPeriod: 'monthly', currency: DEFAULT_CURRENCY }),
|
|
81
|
+
getRecurringPlans(spaceSeq, { billingPeriod: 'yearly', currency: DEFAULT_CURRENCY }),
|
|
82
|
+
]);
|
|
83
|
+
L.push('', `Plans (currency ${DEFAULT_CURRENCY.toUpperCase()} by default; KRW on request):`);
|
|
84
|
+
for (const p of mo.filter((x) => x.tier !== 'enterprise' && x.tier !== 'free')) {
|
|
85
|
+
const m = p.priceOptions[0];
|
|
86
|
+
const y = findPriceOption(yr, p.tier, 'yearly')?.opt;
|
|
87
|
+
L.push(` ${p.tier}: ${m ? `monthly ${money(m)}` : ''}${y ? ` Β· yearly ${money(y)}` : ' Β· (monthly only)'}`);
|
|
88
|
+
}
|
|
89
|
+
L.push('', 'Ask the user which plan and monthly/yearly. For heavy use suggest the top self-serve tier (pro); for very large volume, Enterprise (contact admin).');
|
|
90
|
+
L.push('Then run: node scripts/billing.mjs link --checkout --plan <tier> --period <monthly|yearly> [--currency usd|krw]');
|
|
91
|
+
return void console.log(L.join('\n'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (action === 'billing') {
|
|
95
|
+
const [mo, yr] = await Promise.all([
|
|
96
|
+
getRecurringPlans(spaceSeq, { billingPeriod: 'monthly', currency: DEFAULT_CURRENCY }),
|
|
97
|
+
getRecurringPlans(spaceSeq, { billingPeriod: 'yearly', currency: DEFAULT_CURRENCY }),
|
|
98
|
+
]);
|
|
99
|
+
const period = currentBillingPeriod([...mo, ...yr]) ?? 'monthly';
|
|
100
|
+
const list = period === 'yearly' ? yr : mo;
|
|
101
|
+
const cands = upgradeCandidates(list, tier);
|
|
102
|
+
L.push(`Current billing period: ${period} (a plan change keeps this period)`);
|
|
103
|
+
if (!cands.length) {
|
|
104
|
+
L.push('', 'No higher self-serve plan is available to switch to. For more capacity, contact your administrator (Enterprise).');
|
|
105
|
+
return void console.log(L.join('\n'));
|
|
106
|
+
}
|
|
107
|
+
L.push('', 'Change-plan options:');
|
|
108
|
+
for (const p of cands) L.push(` ${p.tier}: ${money(p.priceOptions.find((x) => x.billingPeriod === period) ?? p.priceOptions[0])}`);
|
|
109
|
+
L.push('', 'Ask the user which plan. Recommend the top self-serve tier (pro) for large needs; for very large volume, Enterprise (contact admin). Currency auto-matches the subscription.');
|
|
110
|
+
L.push('Then run: node scripts/billing.mjs link --billing --plan <tier>');
|
|
111
|
+
return void console.log(L.join('\n'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// one-time (pro/business)
|
|
115
|
+
const prod = await getCreditProduct(spaceSeq);
|
|
116
|
+
if (!prod?.priceId) throw new Error('Credit product unavailable for this space.');
|
|
117
|
+
L.push('', `Credit pack: ${CREDIT_PER_UNIT} credits per pack Β· ${money({ price: prod.unitPrice, priceUnit: prod.priceUnit })}/pack (USD)`);
|
|
118
|
+
if (shortfall != null && shortfall > 0) {
|
|
119
|
+
const rec = recommendCredits(shortfall);
|
|
120
|
+
const cost = prod.unitPrice != null ? `, ${(rec.quantity * Number(prod.unitPrice)).toFixed(2)} ${String(prod.priceUnit).toUpperCase()}` : '';
|
|
121
|
+
L.push(`Shortfall ~${shortfall} credits β recommend ${rec.quantity} pack(s) (${rec.credits} credits${cost}).`);
|
|
122
|
+
L.push('If that quantity or amount is very large, suggest contacting the administrator (Enterprise) instead.');
|
|
123
|
+
} else {
|
|
124
|
+
L.push('Ask the user how many packs to buy.');
|
|
125
|
+
}
|
|
126
|
+
L.push('Then run: node scripts/billing.mjs link --credits --quantity <n>');
|
|
127
|
+
console.log(L.join('\n'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- link: generate the Stripe URL for the confirmed choice ----
|
|
131
|
+
async function runLink(args) {
|
|
132
|
+
const spaceSeq = await resolveSpaceSeq(args.space);
|
|
133
|
+
const modes = [args.checkout && 'checkout', args.billing && 'billing', args.credits && 'credits'].filter(Boolean);
|
|
134
|
+
if (modes.length !== 1) throw new UsageError('Pass exactly one of --checkout, --billing, --credits.');
|
|
135
|
+
const mode = modes[0];
|
|
136
|
+
|
|
137
|
+
if (mode === 'checkout') {
|
|
138
|
+
if (!args.plan || !args.period) throw new UsageError('--checkout needs --plan <tier> and --period <monthly|yearly>.');
|
|
139
|
+
const currency = (args.currency || DEFAULT_CURRENCY).toLowerCase();
|
|
140
|
+
if (!CURRENCIES.includes(currency)) throw new UsageError(`--currency must be one of: ${CURRENCIES.join(', ')}`);
|
|
141
|
+
const plans = await getRecurringPlans(spaceSeq, { billingPeriod: args.period, currency });
|
|
142
|
+
const sel = findPriceOption(plans, args.plan, args.period);
|
|
143
|
+
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()})`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (mode === 'billing') {
|
|
148
|
+
if (!args.plan) throw new UsageError('--billing needs --plan <tier>.');
|
|
149
|
+
// Period is locked to the current subscription and the currency must match it β try the preferred
|
|
150
|
+
// currency, then fall back to the other (PAY4052).
|
|
151
|
+
const preferred = (args.currency || DEFAULT_CURRENCY).toLowerCase();
|
|
152
|
+
if (!CURRENCIES.includes(preferred)) throw new UsageError(`--currency must be one of: ${CURRENCIES.join(', ')}`);
|
|
153
|
+
let currency, link;
|
|
154
|
+
try {
|
|
155
|
+
({ currency, result: link } = await withCurrencyFallback(preferred, async (cur) => {
|
|
156
|
+
const [mo, yr] = await Promise.all([
|
|
157
|
+
getRecurringPlans(spaceSeq, { billingPeriod: 'monthly', currency: cur }),
|
|
158
|
+
getRecurringPlans(spaceSeq, { billingPeriod: 'yearly', currency: cur }),
|
|
159
|
+
]);
|
|
160
|
+
const period = currentBillingPeriod([...mo, ...yr]) ?? 'monthly';
|
|
161
|
+
const sel = findPriceOption(period === 'yearly' ? yr : mo, args.plan, period);
|
|
162
|
+
if (!sel?.opt?.priceId) throw new UsageError(`No ${args.plan} plan found for the current period (${period}).`);
|
|
163
|
+
return createBillingLink({ spaceSeq, priceId: sel.opt.priceId, planSeq: sel.planSeq });
|
|
164
|
+
}));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// Stripe could not create a billing-portal session for this subscription (currency the API can't
|
|
167
|
+
// satisfy, or a subscription that needs attention). Fall back to the self-serve portal instead of
|
|
168
|
+
// surfacing the raw server error.
|
|
169
|
+
if (!isCurrencyMismatch(e)) throw e;
|
|
170
|
+
console.log('Could not generate a direct plan-change link for this subscription automatically.');
|
|
171
|
+
console.log(`Ask the user to change their plan in the Perso portal: ${withUtm(SUBSCRIPTION_URL)}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
return printLink(link, `change to ${args.plan} (${currency.toUpperCase()})`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// credits
|
|
178
|
+
const quantity = Number(args.quantity);
|
|
179
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new UsageError('--credits needs --quantity <positive integer>.');
|
|
180
|
+
const prod = await getCreditProduct(spaceSeq);
|
|
181
|
+
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`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function printLink(link, what) {
|
|
186
|
+
if (!link) throw new Error('The payment service did not return a link. Please try again.');
|
|
187
|
+
console.log(`Payment link (${what}):`);
|
|
188
|
+
console.log(` ${link}`);
|
|
189
|
+
console.log(HANDOFF);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function main() {
|
|
193
|
+
let exitCode = 0;
|
|
194
|
+
try {
|
|
195
|
+
preloadKeyEnv();
|
|
196
|
+
const args = parseArgs(process.argv.slice(2));
|
|
197
|
+
const cmd = args.inputs[0];
|
|
198
|
+
if (args.help || !cmd) console.log(USAGE);
|
|
199
|
+
else if (!resolveKey()) { console.error(onboardingHelp()); exitCode = 2; }
|
|
200
|
+
else if (cmd === 'options') await runOptions(args);
|
|
201
|
+
else if (cmd === 'link') await runLink(args);
|
|
202
|
+
else throw new UsageError(`Unknown command: ${cmd}`);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
if (e?.name === 'ExitCode') exitCode = e.code;
|
|
205
|
+
else if (e?.name === 'UsageError') { console.error(`${e.message}\n\n${USAGE}`); exitCode = 1; }
|
|
206
|
+
else if (e?.name === 'MissingKeyError') { console.error(onboardingHelp()); exitCode = 2; }
|
|
207
|
+
else { console.error(e?.message ?? 'Unknown error'); exitCode = 1; }
|
|
208
|
+
}
|
|
209
|
+
process.exitCode = exitCode;
|
|
210
|
+
setTimeout(() => process.exit(exitCode), 5000).unref();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const isMain = (() => {
|
|
214
|
+
if (!process.argv[1]) return false;
|
|
215
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
|
|
216
|
+
catch { return false; }
|
|
217
|
+
})();
|
|
218
|
+
if (isMain) main();
|
|
219
|
+
|
|
220
|
+
export { parseArgs };
|
|
@@ -13,11 +13,12 @@ import { resolveKey, onboardingHelp, preloadKeyEnv } from './resolve_key.mjs';
|
|
|
13
13
|
import { expandInputs, prepareInput } from '../lib/input.mjs';
|
|
14
14
|
import { dubbingSpaces, getPlanStatus } from '../lib/space.mjs';
|
|
15
15
|
import { getLanguages } from '../lib/languages.mjs';
|
|
16
|
-
import { resolveChunks, recutChunk } from '../lib/split.mjs';
|
|
16
|
+
import { resolveChunks, recutChunk, SplitConfirmNeeded } from '../lib/split.mjs';
|
|
17
17
|
import { runSchedule } from '../lib/scheduler.mjs';
|
|
18
18
|
import { download, getStatus, upload, requestAudioSeparation, downloadSeparation } from '../lib/api_adapter.mjs';
|
|
19
19
|
import { mergeGroups } from '../lib/merge.mjs';
|
|
20
20
|
import { messages, withUtm, SUBSCRIPTION_URL } from '../lib/messages.mjs';
|
|
21
|
+
import { checkForUpdate } from '../lib/update_check.mjs';
|
|
21
22
|
import { cleanupTempDirs, makeTempDir } from '../lib/tmp.mjs';
|
|
22
23
|
import { probe } from '../lib/ffmpeg.mjs';
|
|
23
24
|
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';
|
|
@@ -145,6 +146,7 @@ function parseArgs(argv) {
|
|
|
145
146
|
else if (t === '--separate') a.separate = true;
|
|
146
147
|
else if (t === '--lipsync') a.lipsync = true;
|
|
147
148
|
else if (t === '--force') a.force = true;
|
|
149
|
+
else if (t === '--allow-split') a.allowSplit = true; // user confirmed auto splitβdubβmerge (set on the re-run after [split-confirm])
|
|
148
150
|
else if (t in VALUE_FLAGS) {
|
|
149
151
|
const v = argv[++i];
|
|
150
152
|
if (v === undefined || v.startsWith('--')) throw new UsageError(`Missing value for ${t}`);
|
|
@@ -190,6 +192,34 @@ const refOf = (inp) => (inp.source === 'local'
|
|
|
190
192
|
// Notice text for skipping an unsupported format (append the reason if present).
|
|
191
193
|
const skipMsg = (name, e) => `Skipped (unsupported format): ${name}${e?.cause?.message ? ` (${e.cause.message})` : ''}`;
|
|
192
194
|
|
|
195
|
+
// [split-confirm] prompt: which limit was exceeded + the quality caveat + how to proceed. Emitted (exit 3) before
|
|
196
|
+
// the first split when --allow-split isn't set; the agent relays it and re-runs with --allow-split on the user's OK.
|
|
197
|
+
function splitConfirmMessage(d, tag, action = 'dub') {
|
|
198
|
+
const min = (ms) => Math.max(1, Math.round(ms / 60000));
|
|
199
|
+
const gb = (b) => (b / 1024 ** 3).toFixed(1);
|
|
200
|
+
const label = tag ? `${tag}: ` : '';
|
|
201
|
+
const noun = action === 'separate' ? 'This file' : 'This video';
|
|
202
|
+
let head;
|
|
203
|
+
if (d.reason === 'length') {
|
|
204
|
+
const lim = d.limitMs ? `${min(d.limitMs)} min` : 'the plan limit';
|
|
205
|
+
head = d.actualMs
|
|
206
|
+
? `${noun} exceeds the length limit (${lim}; it is ${min(d.actualMs)} min).`
|
|
207
|
+
: `${noun} exceeds the length limit (${lim}).`;
|
|
208
|
+
} else {
|
|
209
|
+
head = d.actualBytes
|
|
210
|
+
? `${noun} exceeds the 2 GB upload limit (it is ${gb(d.actualBytes)} GB).`
|
|
211
|
+
: `${noun} exceeds the 2 GB upload limit.`;
|
|
212
|
+
}
|
|
213
|
+
const mid = action === 'separate'
|
|
214
|
+
? 'Separating it needs automatic split β separate β merge, which may come out less polished than splitting it up yourself.'
|
|
215
|
+
: 'Dubbing it needs automatic split β dub β merge, which may come out less polished than splitting it up yourself.';
|
|
216
|
+
return [
|
|
217
|
+
`[split-confirm] ${label}${head}`,
|
|
218
|
+
`[split-confirm] ${mid}`,
|
|
219
|
+
'[split-confirm] Proceed automatically? To confirm, re-run the same command with --allow-split.',
|
|
220
|
+
].join('\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
193
223
|
// Save directory (non-volatile): next to the local original; current folder for URL/external/unknown.
|
|
194
224
|
function inputSaveDir(inp) {
|
|
195
225
|
if (inp?.source === 'local' && inp.localPath) return dirname(inp.localPath);
|
|
@@ -476,8 +506,14 @@ async function runPool(args) {
|
|
|
476
506
|
const tag = multiInput ? `[${id + 1}/${inputs.length}] ${labelOf(inp)}` : labelOf(inp);
|
|
477
507
|
let chunks;
|
|
478
508
|
try {
|
|
479
|
-
({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify }));
|
|
509
|
+
({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
|
|
480
510
|
} catch (e) {
|
|
511
|
+
if (e?.name === 'SplitConfirmNeeded') {
|
|
512
|
+
console.log(splitConfirmMessage(e.details, tag));
|
|
513
|
+
// 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
|
+
try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
|
|
515
|
+
throw new ExitCode(3);
|
|
516
|
+
}
|
|
481
517
|
if (isAuthError(e)) { console.log(`\n${friendlyError(e)}`); return; } // key issues abort everything
|
|
482
518
|
if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; } // unsupported β skip
|
|
483
519
|
console.log(`${tag} β split/upload failed: ${friendlyError(e)}`); continue;
|
|
@@ -540,6 +576,7 @@ async function creditPreflight(perInput, spaceSeq, targetCount) {
|
|
|
540
576
|
async function runResume(file) {
|
|
541
577
|
await ensureKey();
|
|
542
578
|
const m = JSON.parse(readFileSync(file, 'utf8'));
|
|
579
|
+
if (m.kind === 'separation') return runResumeSeparation(m, file); // separation has its own (scheduler-less) resume
|
|
543
580
|
if (m.version !== 4 && m.version !== 5) throw new Error('Unsupported state-file format β run again from the original.');
|
|
544
581
|
const targets = m.targets ?? [m.opts?.target ?? 'en'];
|
|
545
582
|
const multiInput = (m.inputs?.length ?? 0) > 1;
|
|
@@ -750,57 +787,188 @@ async function runLipsyncOnly(args) {
|
|
|
750
787
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
751
788
|
const SEPARATION_TRACKS = ['voice', 'background', 'sub_background'];
|
|
752
789
|
|
|
790
|
+
// Separation resume state (version 1, kind 'separation'): the chunk plan + a per-chunk projectId checkpoint.
|
|
791
|
+
// Mirrors the dubbing manifest but keyed by inputId|chunkIndex (no target language).
|
|
792
|
+
function buildSepManifest(ctx, perInput, done) {
|
|
793
|
+
return {
|
|
794
|
+
version: 1, kind: 'separation', spaceSeq: ctx.spaceSeq, out: ctx.out ?? null,
|
|
795
|
+
inputs: perInput.map((p) => ({
|
|
796
|
+
inputId: p.inputId, ref: p.ref,
|
|
797
|
+
chunks: p.chunks.map((c) => ({
|
|
798
|
+
index: c.index, source: c.source, sourceUrl: c.sourceUrl ?? null,
|
|
799
|
+
startMs: c.startMs ?? null, endMs: c.endMs ?? null, title: c.title ?? null, kind: c.kind ?? null,
|
|
800
|
+
})),
|
|
801
|
+
})),
|
|
802
|
+
done, // `${inputId}|${index}` β { status:'RUN'|'OK'|'HARD_FAIL', projectId?, reason? }
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function sepSaver(ctx, perInput, prevDone = {}) {
|
|
806
|
+
const done = { ...prevDone };
|
|
807
|
+
const writeNow = () => {
|
|
808
|
+
try {
|
|
809
|
+
mkdirSync(dirname(ctx.file), { recursive: true });
|
|
810
|
+
const tmp = ctx.file + '.tmp';
|
|
811
|
+
writeFileSync(tmp, JSON.stringify(buildSepManifest(ctx, perInput, done)), 'utf8');
|
|
812
|
+
renameSync(tmp, ctx.file);
|
|
813
|
+
} catch { /* best-effort β saving state must never break the run */ }
|
|
814
|
+
};
|
|
815
|
+
return {
|
|
816
|
+
writeNow, done,
|
|
817
|
+
// The paid projectId is persisted the moment it exists β a hard kill can't cause a re-submission on resume.
|
|
818
|
+
onSubmit: (inputId, index, projectId) => { done[`${inputId}|${index}`] = { status: 'RUN', projectId }; writeNow(); },
|
|
819
|
+
onComplete: (inputId, index, projectId) => { done[`${inputId}|${index}`] = { status: 'OK', projectId }; writeNow(); },
|
|
820
|
+
onFail: (inputId, index, reason) => { done[`${inputId}|${index}`] = { status: 'HARD_FAIL', reason }; writeNow(); },
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
753
824
|
async function runSeparation(args) {
|
|
754
825
|
await ensureKey();
|
|
755
826
|
const inputs = await expandInputs(args.inputs, { recursive: args.recursive });
|
|
827
|
+
const multiInput = inputs.length > 1;
|
|
828
|
+
const file = resumePath({ out: args.out, inputs, multiInput });
|
|
829
|
+
guardExistingState(file); // block re-running the original; --resume continues without re-billing submitted parts
|
|
756
830
|
const spaceSeq = await ensureSpace(args);
|
|
831
|
+
const ctx = { spaceSeq, out: args.out ?? null, file };
|
|
832
|
+
const perInput = [];
|
|
833
|
+
const saver = sepSaver(ctx, perInput);
|
|
834
|
+
// Phase 1 β resolve chunk plans (split-confirm happens here) and persist them before any submission.
|
|
835
|
+
for (let id = 0; id < inputs.length; id++) {
|
|
836
|
+
const inp = inputs[id];
|
|
837
|
+
let chunks;
|
|
838
|
+
try {
|
|
839
|
+
({ chunks } = await resolveChunks(inp, spaceSeq, { log, notify, allowSplit: args.allowSplit }));
|
|
840
|
+
} catch (e) {
|
|
841
|
+
if (e?.name === 'SplitConfirmNeeded') {
|
|
842
|
+
console.log(splitConfirmMessage(e.details, multiInput ? labelOf(inp) : null, 'separate'));
|
|
843
|
+
try { if (existsSync(file)) unlinkSync(file); } catch { /* nothing billed yet β safe to discard */ }
|
|
844
|
+
throw new ExitCode(3);
|
|
845
|
+
}
|
|
846
|
+
if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; }
|
|
847
|
+
notify(`Could not prepare: ${labelOf(inp)} β ${friendlyError(e)}`); continue;
|
|
848
|
+
}
|
|
849
|
+
if (chunks.length > 1) notify(`Split complete β ${labelOf(inp)} (${chunks.length} parts)`);
|
|
850
|
+
perInput.push({ inputId: id, inp, ref: refOf(inp), chunks });
|
|
851
|
+
saver.writeNow();
|
|
852
|
+
}
|
|
853
|
+
if (!perInput.length) { notify('No inputs to separate.'); return; }
|
|
854
|
+
notify('Separating voice/background');
|
|
855
|
+
// Phase 2 β submit (checkpointed) β wait β download β merge β save.
|
|
856
|
+
const pending = await separationProcess(perInput, spaceSeq, ctx, saver, null);
|
|
857
|
+
finishSepState(file, pending);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Drop the state file only when nothing paid is still owed; otherwise keep it and point at --resume (no re-billing).
|
|
861
|
+
function finishSepState(file, pending) {
|
|
862
|
+
if (pending) { notify(`Some parts still need downloading β resume with: node scripts/dubbing.mjs --resume "${file}"`); return; }
|
|
863
|
+
try { if (existsSync(file)) unlinkSync(file); } catch { /* ignore */ }
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Resume: reload the chunk plan, re-cut/re-upload only the parts never submitted, poll/re-download the submitted ones.
|
|
867
|
+
async function runResumeSeparation(m, file) {
|
|
868
|
+
if (m.version !== 1) throw new Error('Unsupported separation state-file format β run again from the original.');
|
|
869
|
+
const spaceSeq = m.spaceSeq;
|
|
870
|
+
const ctx = { spaceSeq, out: m.out ?? null, file };
|
|
871
|
+
const perInput = [];
|
|
872
|
+
const recutCache = new Map(); // `${inputId}|${index}` β re-cut path (once per part)
|
|
873
|
+
for (const pin of m.inputs) {
|
|
874
|
+
const inputStr = pin.ref.source === 'local' ? pin.ref.localPath : pin.ref.sourceUrl;
|
|
875
|
+
let localPath = null; // null when the original can't be re-prepared β materializeFor's guard fires cleanly (server-side parts still resume)
|
|
876
|
+
try { const prepared = await prepareInput(inputStr); localPath = prepared.localPath ?? prepared.path ?? null; }
|
|
877
|
+
catch (e) { console.log(`Original not available: ${pin.ref.originalName ?? inputStr ?? 'input'} (${e.message}) β server-side results only.`); }
|
|
878
|
+
perInput.push({ inputId: pin.inputId, ref: pin.ref, inp: pin.ref, chunks: pin.chunks, _localPath: localPath });
|
|
879
|
+
}
|
|
880
|
+
const saver = sepSaver(ctx, perInput, m.done ?? {});
|
|
881
|
+
const materializeFor = async (pin, c) => {
|
|
882
|
+
if (c.source === 'external') return null; // can't re-cut β upload the source URL
|
|
883
|
+
if (!pin._localPath) throw new Error('Original not found β cannot resume this part.');
|
|
884
|
+
if (c.endMs == null) return pin._localPath; // whole (unsplit) β the original
|
|
885
|
+
const mk = `${pin.inputId}|${c.index}`;
|
|
886
|
+
if (!recutCache.has(mk)) recutCache.set(mk, await recutChunk(pin._localPath, c.startMs, c.endMs));
|
|
887
|
+
return recutCache.get(mk);
|
|
888
|
+
};
|
|
889
|
+
const pending = await separationProcess(perInput, spaceSeq, ctx, saver, materializeFor);
|
|
890
|
+
finishSepState(file, pending);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Per-input: separate every chunk (checkpointing each submission), merge tracks, save. Shared by run + resume.
|
|
894
|
+
// 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) {
|
|
757
896
|
const usedByDir = new Map();
|
|
758
897
|
const lines = [];
|
|
759
|
-
let
|
|
760
|
-
|
|
761
|
-
|
|
898
|
+
let ok = 0, fail = 0;
|
|
899
|
+
const flags = { pending: false };
|
|
900
|
+
const tmp = await makeTempDir('dubbing-sep-');
|
|
901
|
+
for (const pin of perInput) {
|
|
762
902
|
try {
|
|
763
|
-
const
|
|
903
|
+
const byTrack = await separateChunks(pin, spaceSeq, tmp, saver, materializeFor, flags);
|
|
904
|
+
const line = await saveSeparationTracks(pin, byTrack, { out: ctx.out, usedByDir });
|
|
764
905
|
lines.push(line);
|
|
765
|
-
if (line.startsWith('Done'))
|
|
906
|
+
if (line.startsWith('Done')) ok++; else fail++;
|
|
766
907
|
} catch (e) {
|
|
767
|
-
if (e?.httpStatus === 402) { // out of credits β deliver what finished
|
|
908
|
+
if (e?.httpStatus === 402) { // out of credits β deliver what finished + top-up/resume path, then stop
|
|
768
909
|
if (lines.length) console.log('\n' + lines.join('\n'));
|
|
769
910
|
const plan = await getPlanStatus(spaceSeq);
|
|
770
|
-
console.log(messages.quotaExceeded({ planTier: plan?.planTier, remainingQuota: plan?.remainingQuota }));
|
|
911
|
+
console.log(messages.quotaExceeded({ planTier: plan?.planTier, remainingQuota: plan?.remainingQuota, resumeHint: `node scripts/dubbing.mjs --resume "${ctx.file}"` }));
|
|
771
912
|
throw new ExitCode(1);
|
|
772
913
|
}
|
|
773
|
-
if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(inp), e)); continue; }
|
|
774
|
-
|
|
775
|
-
|
|
914
|
+
if (e?.name === 'UnsupportedMediaError') { notify(skipMsg(labelOf(pin.inp ?? pin.ref), e)); continue; }
|
|
915
|
+
flags.pending = true; // errored after some chunks may have been billed β keep state so resume re-downloads them
|
|
916
|
+
fail++;
|
|
917
|
+
lines.push(`Could not separate: ${labelOf(pin.inp ?? pin.ref)} β ${friendlyError(e)}`);
|
|
776
918
|
}
|
|
777
919
|
}
|
|
778
920
|
console.log('\n' + lines.join('\n'));
|
|
779
|
-
if (
|
|
921
|
+
if (perInput.length > 1) console.log(`\nSummary: ${ok} done Β· ${fail} failed`);
|
|
922
|
+
return flags.pending;
|
|
780
923
|
}
|
|
781
924
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
notify(`Separating voice/background β ${labelOf(inp)}`);
|
|
786
|
-
|
|
925
|
+
// Separate one input's chunks. A chunk with a recorded projectId is polled/re-downloaded (no re-submit); the rest are
|
|
926
|
+
// (re-cut/uploaded and) submitted, checkpointing the projectId the instant it exists.
|
|
927
|
+
async function separateChunks(pin, spaceSeq, tmp, saver, materializeFor, flags) {
|
|
787
928
|
const byTrack = new Map(SEPARATION_TRACKS.map((t) => [t, []]));
|
|
788
|
-
const
|
|
789
|
-
for (const c of chunks) {
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
929
|
+
const gap = (index, reason) => { for (const t of SEPARATION_TRACKS) byTrack.get(t).push({ index, status: 'HARD_FAIL', reason }); };
|
|
930
|
+
for (const c of pin.chunks) {
|
|
931
|
+
const prev = saver.done[`${pin.inputId}|${c.index}`];
|
|
932
|
+
if (prev?.status === 'HARD_FAIL') { gap(c.index, prev.reason ?? 'failed'); continue; }
|
|
933
|
+
let projectId = prev?.projectId ?? null;
|
|
934
|
+
try {
|
|
935
|
+
if (projectId == null) { // never submitted β (re-cut/upload and) submit
|
|
936
|
+
let mediaSeq = c.mediaSeq ?? null, kind = c.kind ?? 'video';
|
|
937
|
+
if (mediaSeq == null) {
|
|
938
|
+
const cut = materializeFor ? await materializeFor(pin, c) : null;
|
|
939
|
+
const ref = cut ? { source: 'local', localPath: cut, originalName: basename(cut) } // basename keeps the extension (c.title has none)
|
|
940
|
+
: (c.source === 'external' ? { source: 'external', sourceUrl: c.sourceUrl } : refOf(pin.inp ?? pin.ref));
|
|
941
|
+
({ seq: mediaSeq, kind } = await upload(ref, spaceSeq));
|
|
942
|
+
}
|
|
943
|
+
([projectId] = await requestAudioSeparation(spaceSeq, mediaSeq, { title: c.title, kind }));
|
|
944
|
+
if (projectId == null) throw new Error('Separation request was not accepted.');
|
|
945
|
+
saver.onSubmit(pin.inputId, c.index, projectId); // checkpoint the billed unit before any wait
|
|
946
|
+
if (pin.chunks.length > 1) log(`part ${c.index + 1}/${pin.chunks.length}: separation submitted`);
|
|
947
|
+
}
|
|
948
|
+
if (prev?.status !== 'OK') { // RUN (resume) or freshly submitted β wait for completion
|
|
949
|
+
const st = await waitForProject(projectId, spaceSeq);
|
|
950
|
+
if (st.state !== 'complete') {
|
|
951
|
+
if (st.timedOut) { flags.pending = true; gap(c.index, st.message ?? 'timed out'); continue; } // may still be running (paid) β keep the RUN checkpoint so resume re-polls it
|
|
952
|
+
saver.onFail(pin.inputId, c.index, st.message ?? st.failureReason ?? 'failed'); // genuine server failure β terminal
|
|
953
|
+
gap(c.index, st.message ?? st.failureReason ?? 'failed');
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
saver.onComplete(pin.inputId, c.index, projectId);
|
|
957
|
+
}
|
|
958
|
+
const tracks = await downloadSeparation(projectId, spaceSeq, (label, ext) => join(tmp, `sep_${pin.inputId}_${c.index}_${label}${ext}`));
|
|
959
|
+
for (const t of tracks) byTrack.get(t.label)?.push({ index: c.index, status: 'OK', path: t.path, name: t.fileName });
|
|
960
|
+
} catch (e) {
|
|
961
|
+
if (e?.httpStatus === 402) throw e; // credit-out β let separationProcess deliver finished parts + surface the resume path
|
|
962
|
+
// One part failed to prepare/upload/submit/download β gap it so finished sibling parts still merge & save; keep state so resume retries.
|
|
963
|
+
flags.pending = true;
|
|
964
|
+
gap(c.index, friendlyError(e));
|
|
799
965
|
}
|
|
800
|
-
const tracks = await downloadSeparation(projectId, spaceSeq, (label, ext) => join(tmp, `sep_${c.index}_${label}${ext}`));
|
|
801
|
-
for (const t of tracks) byTrack.get(t.label)?.push({ index: c.index, status: 'OK', path: t.path, name: t.fileName });
|
|
802
966
|
}
|
|
967
|
+
return byTrack;
|
|
968
|
+
}
|
|
803
969
|
|
|
970
|
+
async function saveSeparationTracks(pin, byTrack, { out, usedByDir }) {
|
|
971
|
+
const inp = pin.inp ?? pin.ref;
|
|
804
972
|
const dir = out ?? inputSaveDir(inp); // --separate treats --out as a folder (three tracks per input)
|
|
805
973
|
mkdirSync(dir, { recursive: true });
|
|
806
974
|
const stem = String(labelOf(inp)).replace(/\.[^.\\/]+$/, '') || 'output';
|
|
@@ -836,19 +1004,21 @@ async function waitForProject(projectSeq, spaceSeq) {
|
|
|
836
1004
|
if (st?.state === 'complete' || st?.state === 'failed') return st;
|
|
837
1005
|
const p = st?.progress ?? -1;
|
|
838
1006
|
if (p !== last) { last = p; lastChange = Date.now(); if (p > 0) log(`separating... ${p}%`); }
|
|
839
|
-
if (Date.now() - lastChange > MAX_IDLE_MS) return { state: 'failed', message: 'timed out (no progress)' };
|
|
1007
|
+
if (Date.now() - lastChange > MAX_IDLE_MS) return { state: 'failed', timedOut: true, message: 'timed out (no progress)' };
|
|
840
1008
|
await sleep(POLL_INTERVAL_MS);
|
|
841
1009
|
}
|
|
842
1010
|
}
|
|
843
1011
|
|
|
844
1012
|
// Pure helper exports for testing (when run directly, only main below executes).
|
|
845
|
-
export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes, guardExistingState };
|
|
1013
|
+
export { parseArgs, targetPaths, buildManifest, doneEntry, manifestSaver, finishPool, refOf, resumePath, explicitOutPath, remainingMinutes, guardExistingState, splitConfirmMessage, buildSepManifest, sepSaver };
|
|
846
1014
|
|
|
847
1015
|
async function main() {
|
|
848
1016
|
let exitCode = 0;
|
|
1017
|
+
let updateNotice = null; // daily version check, kicked off in the background and printed after the work finishes
|
|
849
1018
|
try {
|
|
850
1019
|
preloadKeyEnv(); // pre-inject the key into env before async (at a clean point) β avoid a synchronous powershell call/crash in the main process
|
|
851
1020
|
const args = parseArgs(process.argv.slice(2));
|
|
1021
|
+
if (!args.help) updateNotice = checkForUpdate().catch(() => null); // non-blocking; never fails the run
|
|
852
1022
|
if (args.help) console.log(USAGE);
|
|
853
1023
|
else if (args.resume) await runResume(args.resume);
|
|
854
1024
|
else if (args.separate) {
|
|
@@ -870,6 +1040,8 @@ async function main() {
|
|
|
870
1040
|
} finally {
|
|
871
1041
|
await cleanupTempDirs(); // bulk-clean the cut/schedule/merge/download temp folders
|
|
872
1042
|
}
|
|
1043
|
+
// After the work is done (never mid-job), surface a version-update notice if one is available.
|
|
1044
|
+
if (updateNotice) { try { const n = await updateNotice; if (n) console.log('\n' + n); } catch { /* ignore */ } }
|
|
873
1045
|
// Natural exit (loop drain) β process.exit() while fetch sockets are closing hits a Windows libuv assert
|
|
874
1046
|
// (async.c) that corrupts the exit code. The unref'd timer is a hard-exit fallback if a handle ever hangs.
|
|
875
1047
|
process.exitCode = exitCode;
|
|
@@ -18,7 +18,7 @@ function parseArg(arg) {
|
|
|
18
18
|
try {
|
|
19
19
|
const prepared = parseArg(process.argv[2]);
|
|
20
20
|
const spaceSeq = Number(process.env.PERSO_SPACE_SEQ) || (await resolveSpace());
|
|
21
|
-
const result = await resolveChunks(prepared, spaceSeq);
|
|
21
|
+
const result = await resolveChunks(prepared, spaceSeq, { allowSplit: true }); // debug tool: split without the pre-confirm gate
|
|
22
22
|
console.log(JSON.stringify(result));
|
|
23
23
|
} catch (e) {
|
|
24
24
|
console.error(e.message);
|