perso-dubbing 0.2.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 +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +3 -2
- package/README.md +12 -3
- package/package.json +2 -2
- package/skills/dubbing/SKILL.md +92 -22
- package/skills/dubbing/lib/api_adapter.mjs +69 -10
- package/skills/dubbing/lib/billing.mjs +159 -0
- package/skills/dubbing/lib/config.mjs +27 -1
- package/skills/dubbing/lib/ffmpeg.mjs +41 -1
- package/skills/dubbing/lib/merge.mjs +23 -2
- package/skills/dubbing/lib/messages.mjs +12 -17
- package/skills/dubbing/lib/scheduler.mjs +117 -15
- 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 +572 -47
- package/skills/dubbing/scripts/probe_split.mjs +1 -1
|
@@ -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 };
|