@homie-rocks/studio 0.51.3 → 0.51.5
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/CHANGELOG.md +45 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/docs/mcp-events.md +68 -0
- package/guides/app/SKILL.md +25 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/office/SKILL.md +24 -0
- package/guides/parts/SKILL.md +1 -1
- package/guides/servers/SKILL.md +24 -0
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/functions-build.mjs +8 -1
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +87 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +12 -3
- package/worker/event-webhooks.mjs +14 -9
- package/worker/functions.mjs +11 -6
- package/worker/index.mjs +5 -1
- package/worker/mcp-event-streams.mjs +58 -0
- package/worker/mcp-events-wire.mjs +41 -0
- package/worker/mcp-events.mjs +132 -0
- package/worker/mcp.mjs +4 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripeEventOwner } from './stripe-ownership.mjs';
|
|
1
2
|
import { hasStripeKey, shopMode, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
|
|
2
3
|
import { paymentRefunds } from './shop-refunds.mjs';
|
|
3
4
|
import { orderFact, bindHostedSession } from './purchase-core.mjs';
|
|
@@ -303,11 +304,25 @@ export async function reconcileOrder(env, o) {
|
|
|
303
304
|
|
|
304
305
|
/** Called only AFTER the existing shop webhook signature/mode check. Return null for player orders. */
|
|
305
306
|
export async function purchasePaymentEvent(env, event) {
|
|
307
|
+
const obj = event.data?.object ?? {};
|
|
308
|
+
const owner = stripeEventOwner(obj);
|
|
309
|
+
if (owner === 'shop') return null;
|
|
310
|
+
// Stripe can omit metadata on lifecycle events; an established shop subscription
|
|
311
|
+
// still belongs to the shop. Explicit ownership takes precedence over ID lookup.
|
|
312
|
+
if (!owner && (event.type.startsWith('invoice.') || event.type.startsWith('customer.subscription.'))) {
|
|
313
|
+
const subscription = event.type.startsWith('customer.subscription.') ? obj.id : idOf(obj.parent?.subscription_details?.subscription ?? obj.subscription);
|
|
314
|
+
if (subscription) {
|
|
315
|
+
try {
|
|
316
|
+
if (await env.DB.prepare('SELECT 1 FROM shop_subscriptions WHERE id = ?1').bind(subscription).first()) return null;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (!/no such table: (?:main\.)?shop_subscriptions\b/.test(String(error?.message))) throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
306
322
|
// An existing player shop may receive webhooks before purchase migrations run.
|
|
307
323
|
try { await env.DB.prepare("SELECT 1 FROM purchase_orders LIMIT 0").first(); }
|
|
308
324
|
catch (error) { if (/no such table/.test(error.message)) return null; throw error; }
|
|
309
325
|
|
|
310
|
-
const obj = event.data?.object ?? {};
|
|
311
326
|
if (!event.type.startsWith('checkout.session.')) await rememberMoneyEvent(env,event);
|
|
312
327
|
if (obj.payment_link && obj.metadata?.homie === 'purchase-links-v1') return reconcileLinkSession(env,obj);
|
|
313
328
|
if (!hasStripeKey(env) && event.type.startsWith('customer.subscription.')) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripeEventOwner } from './stripe-ownership.mjs';
|
|
1
2
|
import { linkConfig, hasStripeKey } from './shop-links.mjs';
|
|
2
3
|
import { KEY_SHAPE, stripeCall } from './stripe.mjs';
|
|
3
4
|
/** Only verified shop Checkout sessions establish ownership. Provider customer IDs
|
|
@@ -48,6 +49,7 @@ export async function bindSubscription(env, order, session, shop) {
|
|
|
48
49
|
export async function subscriptionEvent(env, event, shop) {
|
|
49
50
|
if (!['invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted'].includes(event.type)) return null;
|
|
50
51
|
const obj=event.data.object;
|
|
52
|
+
if (stripeEventOwner(obj) === 'parts') return null;
|
|
51
53
|
const subscription=event.type.startsWith('customer.subscription.') ? obj.id : idOf(obj.parent?.subscription_details?.subscription ?? obj.subscription);
|
|
52
54
|
if (!subscription) return 'ignored';
|
|
53
55
|
let row;
|
package/worker/shop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripeEventOwner } from './stripe-ownership.mjs';
|
|
1
2
|
import { localSession } from './local-test.mjs';
|
|
2
3
|
import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
|
|
3
4
|
import { bookingStatement } from './booking-store.mjs';
|
|
@@ -162,10 +163,10 @@ export const MISSING_WORDS = Object.freeze({
|
|
|
162
163
|
'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
|
|
163
164
|
'till-off': 'shop.json says "till": "off".',
|
|
164
165
|
items: 'shop.json has no items.',
|
|
165
|
-
'payment-links': '
|
|
166
|
-
'fuller-checkout': '
|
|
167
|
-
'stripe-key': '
|
|
168
|
-
'webhook-secret': '
|
|
166
|
+
'payment-links': 'Ask your AI to plan and verify this shop through Stripe’s official plugin, then install its private webhook (homie-studio shop plan, shop verify, shop webhook).',
|
|
167
|
+
'fuller-checkout': 'These spending allowance, expiry or recurring amount settings are not supported by Payment Links. Ask your AI to review them with you before setup.',
|
|
168
|
+
'stripe-key': 'This older checkout connection is unavailable. Ask your AI to set up Payment Links through Stripe’s official plugin.',
|
|
169
|
+
'webhook-secret': 'Ask your AI to run homie-studio shop webhook after verifying the Stripe plugin setup; it installs the signing secret privately.',
|
|
169
170
|
database: 'This Worker has no database.',
|
|
170
171
|
migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0012_shop_lines.sql.',
|
|
171
172
|
});
|
|
@@ -832,6 +833,7 @@ async function hook(request, env, cat, shop, ready) {
|
|
|
832
833
|
if (payment) await syncRefunds(env, payment);
|
|
833
834
|
await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run(); return json({ ok: true, did: purchase }); }
|
|
834
835
|
}
|
|
836
|
+
if (stripeEventOwner(obj) === 'parts') return json({ ok: true, did: 'ignored' });
|
|
835
837
|
needed.push('payment_link.updated','invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted');
|
|
836
838
|
if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
|
|
837
839
|
if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
|
|
@@ -1194,7 +1196,7 @@ async function disputeClosed(env, payment, status) {
|
|
|
1194
1196
|
}
|
|
1195
1197
|
|
|
1196
1198
|
/** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
|
|
1197
|
-
export const REFUND_HELD = 'Stripe is holding this refund
|
|
1199
|
+
export const REFUND_HELD = 'Stripe is holding this refund for your approval in Stripe (Settings, Approvals, Requests). Approve it there; the item leaves the player’s account after Stripe confirms the refund. You can also manage refunds through your connected Stripe AI; no credential change is needed.';
|
|
1198
1200
|
|
|
1199
1201
|
/** Refund selected lines through Stripe, revoking each only after success. */
|
|
1200
1202
|
export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner', line: lineId = null } = {}) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Subscription metadata takes precedence over invoice-level metadata. */
|
|
2
|
+
export function stripeEventOwner(object) {
|
|
3
|
+
const metadata = object.parent?.subscription_details?.metadata ?? object.subscription_details?.metadata ?? object.metadata;
|
|
4
|
+
if (['shop-links-v1', 'shop-v1'].includes(metadata?.homie)) return 'shop';
|
|
5
|
+
if (['purchase-links-v1', 'purchase-v1'].includes(metadata?.homie)) return 'parts';
|
|
6
|
+
return null;
|
|
7
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.51.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.5';
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"v": 1,
|
|
3
|
-
"checked": "2026-10-03",
|
|
4
|
-
"note": "The endpoints the models skill uses, with the prices and schemas read live on the date above (fal's pricing API, its OpenAPI pages, the providers' terms). Endpoints retire and reprice monthly: `models.mjs registry` reads every price again for free and flags what moved; every receipt names the exact endpoint. Never hard-code one of these anywhere else.",
|
|
5
|
-
"concept": {
|
|
6
|
-
"withRefs": {
|
|
7
|
-
"endpoint": "fal-ai/bytedance/seedream/v5/lite/edit",
|
|
8
|
-
"price": {
|
|
9
|
-
"usd": 0.035,
|
|
10
|
-
"per": "image",
|
|
11
|
-
"read": "2026-10-02"
|
|
12
|
-
},
|
|
13
|
-
"input": {
|
|
14
|
-
"num_images": 1,
|
|
15
|
-
"image_size": {
|
|
16
|
-
"width": 2048,
|
|
17
|
-
"height": 2048
|
|
18
|
-
}
|
|
19
|
-
},
|
|
20
|
-
"refs": "image_urls",
|
|
21
|
-
"maxRefs": 10,
|
|
22
|
-
"why": "the cheapest multi-reference editor: the golden images keep every prop in one style"
|
|
23
|
-
},
|
|
24
|
-
"noRefs": {
|
|
25
|
-
"endpoint": "fal-ai/bytedance/seedream/v5/lite/text-to-image",
|
|
26
|
-
"price": {
|
|
27
|
-
"usd": 0.035,
|
|
28
|
-
"per": "image",
|
|
29
|
-
"read": "2026-10-02"
|
|
30
|
-
},
|
|
31
|
-
"input": {
|
|
32
|
-
"num_images": 1,
|
|
33
|
-
"image_size": {
|
|
34
|
-
"width": 2048,
|
|
35
|
-
"height": 2048
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
"why": "before there are golden images; the derived style prompt carries the look"
|
|
39
|
-
},
|
|
40
|
-
"minPixels": 3686400
|
|
41
|
-
},
|
|
42
|
-
"mesh": {
|
|
43
|
-
"prop": {
|
|
44
|
-
"endpoint": "tripo3d/p1/image-to-3d",
|
|
45
|
-
"price": {
|
|
46
|
-
"usd": 0.5,
|
|
47
|
-
"per": "model with textures (0.40 without)",
|
|
48
|
-
"read": "2026-10-02"
|
|
49
|
-
},
|
|
50
|
-
"credits": {
|
|
51
|
-
"base": 40,
|
|
52
|
-
"texture": 10,
|
|
53
|
-
"defaults": {
|
|
54
|
-
"texture": true
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
"input": {
|
|
58
|
-
"texture": true
|
|
59
|
-
},
|
|
60
|
-
"faceLimit": "face_limit",
|
|
61
|
-
"output": "model_mesh",
|
|
62
|
-
"why": "Tripo P1: built for low-poly game meshes, a face_limit that reaches a phone budget, $0.50 textured"
|
|
63
|
-
},
|
|
64
|
-
"fallbacks": [
|
|
65
|
-
{
|
|
66
|
-
"endpoint": "fal-ai/hyper3d/rodin/v2.5",
|
|
67
|
-
"price": {
|
|
68
|
-
"usd": 0.4,
|
|
69
|
-
"per": "generation",
|
|
70
|
-
"read": "2026-10-02"
|
|
71
|
-
},
|
|
72
|
-
"why": "quad presets and texture_delight; not wired by default"
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
"endpoint": "fal-ai/trellis",
|
|
76
|
-
"price": {
|
|
77
|
-
"usd": 0.02,
|
|
78
|
-
"per": "generation",
|
|
79
|
-
"read": "2026-10-02"
|
|
80
|
-
},
|
|
81
|
-
"why": "drafts for exploring, then local simplify; not wired by default"
|
|
82
|
-
}
|
|
83
|
-
]
|
|
84
|
-
},
|
|
85
|
-
"mood": {
|
|
86
|
-
"endpoint": "fal-ai/bytedance/seedream/v5/lite/text-to-image",
|
|
87
|
-
"price": {
|
|
88
|
-
"usd": 0.035,
|
|
89
|
-
"per": "image",
|
|
90
|
-
"read": "2026-10-02"
|
|
91
|
-
},
|
|
92
|
-
"input": {
|
|
93
|
-
"num_images": 1,
|
|
94
|
-
"image_size": {
|
|
95
|
-
"width": 2560,
|
|
96
|
-
"height": 1440
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
"terms": [
|
|
101
|
-
{
|
|
102
|
-
"who": "fal",
|
|
103
|
-
"url": "https://fal.ai/terms",
|
|
104
|
-
"read": "2026-10-02",
|
|
105
|
-
"lastUpdated": "2026-09-08",
|
|
106
|
-
"says": "Customer owns its input; no express assignment of output; output not promised original or non-infringing; no training competing models on third-party outputs."
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
"who": "Tripo (developer terms)",
|
|
110
|
-
"url": "https://developers.tripo3d.ai/en/terms",
|
|
111
|
-
"read": "2026-10-02",
|
|
112
|
-
"lastUpdated": "2025-07-11",
|
|
113
|
-
"says": "\"you may use Outputs for lawful commercial or non-commercial purposes\"."
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
"who": "Meshy (through fal)",
|
|
117
|
-
"url": "https://fal.ai/models/meshy/v7.1/image-to-3d",
|
|
118
|
-
"read": "2026-10-03",
|
|
119
|
-
"says": "fal labels the endpoint \"Commercial use\"; calls through fal are paid calls on the person's own fal account (fal's terms apply to the output). Meshy's own free plan (CC BY 4.0, Meshy owns output) does not apply: this skill never uses a Meshy free plan."
|
|
120
|
-
}
|
|
121
|
-
],
|
|
122
|
-
"character": {
|
|
123
|
-
"mesh": {
|
|
124
|
-
"endpoint": "meshy/v7.1/image-to-3d",
|
|
125
|
-
"price": {
|
|
126
|
-
"usd": 1.4,
|
|
127
|
-
"per": "textured model with auto-rigging: US$0.80 base + US$0.40 textures + US$0.20 rigging (+US$0.12 an animation preset, not used)",
|
|
128
|
-
"read": "2026-10-03",
|
|
129
|
-
"api": "the pricing API says US$0.80 a generation (the base); the page's add-ons are added on top"
|
|
130
|
-
},
|
|
131
|
-
"addons": {
|
|
132
|
-
"base": 0.8,
|
|
133
|
-
"should_texture": 0.4,
|
|
134
|
-
"enable_rigging": 0.2,
|
|
135
|
-
"enable_animation": 0.12,
|
|
136
|
-
"defaults": {
|
|
137
|
-
"should_texture": true
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
"input": {
|
|
141
|
-
"model_type": "standard",
|
|
142
|
-
"topology": "triangle",
|
|
143
|
-
"target_polycount": 7000,
|
|
144
|
-
"should_remesh": true,
|
|
145
|
-
"should_texture": true,
|
|
146
|
-
"enable_pbr": false,
|
|
147
|
-
"pose_mode": "a-pose",
|
|
148
|
-
"enable_rigging": true,
|
|
149
|
-
"enable_animation": false,
|
|
150
|
-
"symmetry_mode": "on"
|
|
151
|
-
},
|
|
152
|
-
"polycount": "target_polycount",
|
|
153
|
-
"height": "rigging_height_meters",
|
|
154
|
-
"output": "rigged_character_glb",
|
|
155
|
-
"why": "Meshy 7.1 through fal: a textured mesh, a humanoid auto-rig and basic walk and run clips in one call, on the person's own fal account; the library's clips are retargeted onto its skeleton for free"
|
|
156
|
-
},
|
|
157
|
-
"rig": {
|
|
158
|
-
"endpoint": "fal-ai/meshy/rigging",
|
|
159
|
-
"price": {
|
|
160
|
-
"usd": 0.8,
|
|
161
|
-
"per": "request (the page says US$0.20; the pricing API says US$0.80: quoted at the higher until they agree)",
|
|
162
|
-
"read": "2026-10-03"
|
|
163
|
-
},
|
|
164
|
-
"addons": {
|
|
165
|
-
"base": 0.2,
|
|
166
|
-
"enable_animation": 0.12
|
|
167
|
-
},
|
|
168
|
-
"input": {
|
|
169
|
-
"enable_animation": false
|
|
170
|
-
},
|
|
171
|
-
"why": "rig a humanoid mesh made elsewhere (a Tripo prop-style mesh, the person's own); humanoids with clear limbs only; not wired by default"
|
|
172
|
-
},
|
|
173
|
-
"concept": "uses concept.withRefs or concept.noRefs: one full-body image in an A-pose, front view, plain background"
|
|
174
|
-
}
|
|
175
|
-
}
|
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { providerKey } from '../../../../hooks/lib/provider-key.mjs';
|
|
2
|
-
/**
|
|
3
|
-
* ElevenLabs, through the creator's OWN account, by one of two roads:
|
|
4
|
-
*
|
|
5
|
-
* cli the official `elevenlabs` CLI (github.com/elevenlabs/cli), signed in with
|
|
6
|
-
* its own browser sign-in (`elevenlabs auth login`); the sign-in stays in the
|
|
7
|
-
* OS keychain and nothing here ever sees it;
|
|
8
|
-
* key ELEVENLABS_API_KEY in the environment (the person's own key; never
|
|
9
|
-
* written into the studio, never printed), or, for a studio set up through
|
|
10
|
-
* Stripe Projects (`homie-studio setup --via stripe-projects --with elevenlabs`;
|
|
11
|
-
* studio.json providers.elevenlabs), the key Projects synced to the studio's
|
|
12
|
-
* git-ignored .env: read here, in this process only, never printed or passed on.
|
|
13
|
-
*
|
|
14
|
-
* ELEVENLABS_BASE_URL points either road at another address (a test double).
|
|
15
|
-
*/
|
|
16
|
-
import { spawnSync } from 'node:child_process';
|
|
17
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
-
import { basename, dirname, isAbsolute, join, normalize, sep } from 'node:path';
|
|
19
|
-
|
|
20
|
-
const BASE = () => (process.env.ELEVENLABS_BASE_URL || 'https://api.elevenlabs.io').replace(/\/+$/, '');
|
|
21
|
-
const CLI = () => process.env.ELEVENLABS_CLI || 'elevenlabs';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Measured on a Creator-plan account, 2026-09-28: music_v2 composition-plan renders
|
|
25
|
-
* billed 1,015 credits for 37 s and 5,204 credits for 189.2 s, about 27.5 credits a
|
|
26
|
-
* second. The quote uses this; every render then records what it really cost.
|
|
27
|
-
*/
|
|
28
|
-
export const MUSIC_CREDITS_PER_SECOND = 27.5;
|
|
29
|
-
/** Speech-to-text (the lyric check) is small next to a render; measured per run and recorded. */
|
|
30
|
-
export const STT_CREDITS_PER_SECOND_ESTIMATE = 0.5;
|
|
31
|
-
|
|
32
|
-
function cli(args, { input, timeout = 15 * 60_000 } = {}) {
|
|
33
|
-
const base = process.env.ELEVENLABS_BASE_URL ? ['--base-url', process.env.ELEVENLABS_BASE_URL] : [];
|
|
34
|
-
const r = spawnSync(CLI(), [...args, ...base], { input: typeof input === 'string' ? Buffer.from(input) : input, maxBuffer: 512 * 1024 * 1024, timeout });
|
|
35
|
-
if (r.error) return { code: 127, stdout: Buffer.alloc(0), stderr: r.error.message };
|
|
36
|
-
return { code: r.status ?? 1, stdout: r.stdout ?? Buffer.alloc(0), stderr: (r.stderr ?? Buffer.alloc(0)).toString('utf8') };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const parse = (buf) => { try { return JSON.parse(buf.toString('utf8')); } catch { return null; } };
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The ElevenLabs key Stripe Projects keeps for this studio, or null: studio.json (found from the working folder up)
|
|
43
|
-
* says `providers.elevenlabs.via: "stripe-projects"` and which variable of which output file holds it.
|
|
44
|
-
*/
|
|
45
|
-
export function projectsKey(from = process.cwd()) {
|
|
46
|
-
let dir = from;
|
|
47
|
-
for (let i = 0; i < 12; i += 1) {
|
|
48
|
-
if (existsSync(join(dir, 'studio.json'))) break;
|
|
49
|
-
const up = dirname(dir);
|
|
50
|
-
if (up === dir) return null;
|
|
51
|
-
dir = up;
|
|
52
|
-
}
|
|
53
|
-
let p = null;
|
|
54
|
-
try { p = JSON.parse(readFileSync(join(dir, 'studio.json'), 'utf8'))?.providers?.elevenlabs ?? null; } catch { return null; }
|
|
55
|
-
if (p?.via !== 'stripe-projects') return null;
|
|
56
|
-
const name = /^[A-Z][A-Z0-9_]{2,}$/.test(String(p.envKey ?? '')) ? p.envKey : 'ELEVENLABS_API_KEY';
|
|
57
|
-
const file = normalize(String(p.envFile || '.env'));
|
|
58
|
-
if (isAbsolute(file) || file === '..' || file.startsWith(`..${sep}`)) return null;
|
|
59
|
-
let text = '';
|
|
60
|
-
try { text = readFileSync(join(dir, file), 'utf8'); } catch { return null; }
|
|
61
|
-
for (const line of text.split(/\r?\n/)) {
|
|
62
|
-
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
63
|
-
if (!m || m[1] !== name) continue;
|
|
64
|
-
const v = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
65
|
-
return v || null;
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
const keyOf = () => providerKey('elevenlabs').key || projectsKey();
|
|
70
|
-
|
|
71
|
-
/** Which road is open, without spending anything and without printing a secret. */
|
|
72
|
-
export function road() {
|
|
73
|
-
const probe = cli(['--version']);
|
|
74
|
-
const hasCli = probe.code === 0;
|
|
75
|
-
let signedIn = false;
|
|
76
|
-
if (hasCli) {
|
|
77
|
-
const s = parse(cli(['auth', 'status', '--format', 'json']).stdout);
|
|
78
|
-
signedIn = Boolean(s?.schemes?.some((x) => x.logged_in));
|
|
79
|
-
}
|
|
80
|
-
const hasKey = Boolean(keyOf());
|
|
81
|
-
if (hasCli && signedIn) return { road: 'cli', cli: probe.stdout.toString('utf8').trim() };
|
|
82
|
-
if (hasKey) return { road: 'key', via: providerKey('elevenlabs').source === 'ELEVENLABS_API_KEY' ? 'environment' : providerKey('elevenlabs').key ? 'key-file' : 'stripe-projects', source: providerKey('elevenlabs').source ?? 'stripe-projects', cli: hasCli ? probe.stdout.toString('utf8').trim() : null, cliSignedIn: false };
|
|
83
|
-
return {
|
|
84
|
-
road: null, cli: hasCli ? probe.stdout.toString('utf8').trim() : null, cliSignedIn: false,
|
|
85
|
-
why: hasCli
|
|
86
|
-
? 'the elevenlabs CLI is here but not signed in: run `elevenlabs auth login` (it opens ElevenLabs in the browser; the person signs in once)'
|
|
87
|
-
: 'no ElevenLabs connection: install the official CLI (`brew install elevenlabs/tap/elevenlabs`, then `elevenlabs auth login`), or set ELEVENLABS_API_KEY from https://elevenlabs.io/app/developers/api-keys in the environment',
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function api(path, { method = 'GET', json, form, query } = {}) {
|
|
92
|
-
const key = keyOf();
|
|
93
|
-
if (!key) throw new Error('ELEVENLABS_API_KEY is not set (and no Stripe Projects key for this studio)');
|
|
94
|
-
const url = new URL(`${BASE()}${path}`);
|
|
95
|
-
for (const [k, v] of Object.entries(query ?? {})) if (v != null) url.searchParams.set(k, String(v));
|
|
96
|
-
const headers = { 'xi-api-key': key };
|
|
97
|
-
let body;
|
|
98
|
-
if (json) { headers['content-type'] = 'application/json'; body = JSON.stringify(json); }
|
|
99
|
-
if (form) body = form;
|
|
100
|
-
const res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(15 * 60_000) });
|
|
101
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
102
|
-
if (!res.ok) throw new Error(`ElevenLabs ${method} ${path} answered ${res.status}: ${buf.toString('utf8').slice(0, 300)}`);
|
|
103
|
-
return { headers: res.headers, body: buf };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Plan and credit facts. `used`/`limit` are ElevenLabs' character_count/character_limit (its credits). */
|
|
107
|
-
export async function subscription(r = road()) {
|
|
108
|
-
let s;
|
|
109
|
-
if (r.road === 'cli') {
|
|
110
|
-
const out = cli(['user', 'subscription', 'get', '--format', 'json']);
|
|
111
|
-
s = parse(out.stdout);
|
|
112
|
-
if (out.code !== 0 || !s) throw new Error(`could not read the ElevenLabs subscription: ${out.stderr.trim().split('\n').pop() || out.stdout.toString('utf8').slice(0, 200)}`);
|
|
113
|
-
} else if (r.road === 'key') s = parse((await api('/v1/user/subscription')).body);
|
|
114
|
-
else throw new Error(r.why);
|
|
115
|
-
const used = Number(s.character_count); const limit = Number(s.character_limit);
|
|
116
|
-
return {
|
|
117
|
-
tier: s.tier ?? null, status: s.status ?? null, used, limit, remaining: limit - used,
|
|
118
|
-
resetAt: s.next_character_count_reset_unix ? new Date(s.next_character_count_reset_unix * 1000).toISOString() : null,
|
|
119
|
-
canGoOver: Boolean(s.can_extend_character_limit || s.allowed_to_extend_character_limit),
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* What the plan means for rights, in plain words (ElevenLabs help centre, "Can I
|
|
125
|
-
* publish the content I generate on the platform?", read 2026-09-29): the free plan
|
|
126
|
-
* has no commercial licence and asks for "Eleven Music" attribution; paid plans
|
|
127
|
-
* include a commercial licence (not for Beta services), under the Terms of Service
|
|
128
|
-
* and the Prohibited Use Policy. The tier is recorded with every render because it
|
|
129
|
-
* is the tier AT RENDER TIME that counts: upgrading later does not relicense a
|
|
130
|
-
* render made on the free plan.
|
|
131
|
-
*/
|
|
132
|
-
export function rightsFor(tier) {
|
|
133
|
-
const t = String(tier ?? '').toLowerCase();
|
|
134
|
-
if (!t) return { plan: null, commercial: null, attribution: null, words: 'The plan could not be read, so the rights are unknown: do not publish commercially until it is.' };
|
|
135
|
-
if (t === 'free') {
|
|
136
|
-
return { plan: t, commercial: false, attribution: 'Made with Eleven Music.', words: 'Free plan: no commercial use (no ads, no sales, no monetised channels), and published copies credit "Eleven Music". A paid plan is needed for commercial use, and a render made now stays under the free-plan terms.' };
|
|
137
|
-
}
|
|
138
|
-
return { plan: t, commercial: true, attribution: null, words: `Paid plan (${t}): ElevenLabs includes a commercial licence for music made on it (not for Beta features), within their Terms of Service and Prohibited Use Policy. It is recorded with the render, because the plan at render time is what counts.` };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export const quoteCredits = (seconds, { vocals = false } = {}) => {
|
|
142
|
-
const music = Math.ceil(seconds * MUSIC_CREDITS_PER_SECOND);
|
|
143
|
-
const stt = vocals ? Math.ceil(seconds * STT_CREDITS_PER_SECOND_ESTIMATE) : 0;
|
|
144
|
-
return { music, lyricCheck: stt, total: music + stt, basis: `about ${MUSIC_CREDITS_PER_SECOND} credits per second of music_v2 (measured on a Creator-plan account); the lyric check is an estimate` };
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
/** The free check: the CLI validates the body locally and sends nothing. */
|
|
148
|
-
export function dryRun(body, { outputFormat }) {
|
|
149
|
-
const r = road();
|
|
150
|
-
if (r.road !== 'cli') return { ok: true, sent: false, road: r.road, body, note: 'no CLI to validate with: the body is shown as it would be sent' };
|
|
151
|
-
const out = cli(['music', 'compose_detailed', '--dry-run', '--format', 'json', '--output-format', outputFormat, '--json', '-'], { input: JSON.stringify(body) });
|
|
152
|
-
const j = parse(out.stdout);
|
|
153
|
-
if (out.code !== 0 || !j || j.error) return { ok: false, why: j?.error?.message ?? out.stderr.trim() };
|
|
154
|
-
return { ok: true, sent: false, road: 'cli', request: { url: j.url, query: j.query_params, body: j.body } };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Split a multipart/mixed answer (JSON + audio) into its parts. */
|
|
158
|
-
export function splitMultipart(buf, contentType) {
|
|
159
|
-
const m = /boundary="?([^";]+)"?/i.exec(contentType ?? '');
|
|
160
|
-
let boundary = m ? m[1] : null;
|
|
161
|
-
if (!boundary) {
|
|
162
|
-
const first = buf.indexOf('\r\n');
|
|
163
|
-
const line = buf.subarray(0, first).toString('latin1');
|
|
164
|
-
if (line.startsWith('--')) boundary = line.slice(2);
|
|
165
|
-
}
|
|
166
|
-
if (!boundary) throw new Error('the answer is not multipart');
|
|
167
|
-
const sep = Buffer.from(`--${boundary}`);
|
|
168
|
-
const parts = [];
|
|
169
|
-
let at = buf.indexOf(sep);
|
|
170
|
-
while (at >= 0) {
|
|
171
|
-
const next = buf.indexOf(sep, at + sep.length);
|
|
172
|
-
if (next < 0) break;
|
|
173
|
-
let part = buf.subarray(at + sep.length, next);
|
|
174
|
-
if (part.subarray(0, 2).toString() === '\r\n') part = part.subarray(2);
|
|
175
|
-
if (part.subarray(part.length - 2).toString() === '\r\n') part = part.subarray(0, part.length - 2);
|
|
176
|
-
const headEnd = part.indexOf('\r\n\r\n');
|
|
177
|
-
const head = part.subarray(0, headEnd).toString('latin1').toLowerCase();
|
|
178
|
-
parts.push({ head, body: part.subarray(headEnd + 4) });
|
|
179
|
-
at = next;
|
|
180
|
-
}
|
|
181
|
-
const json = parts.find((p) => p.head.includes('application/json'));
|
|
182
|
-
const audio = parts.find((p) => /audio|octet-stream/.test(p.head));
|
|
183
|
-
return { json: json ? JSON.parse(json.body.toString('utf8')) : null, audio: audio?.body ?? null };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Split `--format http` output (status line, headers, body). */
|
|
187
|
-
function splitHttp(buf) {
|
|
188
|
-
const end = buf.indexOf('\r\n\r\n') >= 0 ? buf.indexOf('\r\n\r\n') : buf.indexOf('\n\n');
|
|
189
|
-
const sepLen = buf.indexOf('\r\n\r\n') >= 0 ? 4 : 2;
|
|
190
|
-
const head = buf.subarray(0, end).toString('latin1');
|
|
191
|
-
const [status, ...lines] = head.split(/\r?\n/);
|
|
192
|
-
const headers = {};
|
|
193
|
-
for (const l of lines) { const i = l.indexOf(':'); if (i > 0) headers[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); }
|
|
194
|
-
let body = buf.subarray(end + sepLen);
|
|
195
|
-
if (/chunked/i.test(headers['transfer-encoding'] ?? '') && !/^--/.test(body.subarray(0, 2).toString())) {
|
|
196
|
-
// De-chunk (the CLI normally hands the body over already joined).
|
|
197
|
-
const out = []; let i = 0;
|
|
198
|
-
while (i < body.length) { const nl = body.indexOf('\r\n', i); const n = parseInt(body.subarray(i, nl).toString(), 16); if (!n) break; out.push(body.subarray(nl + 2, nl + 2 + n)); i = nl + 2 + n + 2; }
|
|
199
|
-
body = Buffer.concat(out);
|
|
200
|
-
}
|
|
201
|
-
return { status: Number(status.split(' ')[1]), headers, body };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** One paid render: audio, the plan it used, word timestamps, and the song id (for inpainting). */
|
|
205
|
-
export async function composeDetailed(body, { outputFormat }) {
|
|
206
|
-
const r = road();
|
|
207
|
-
if (r.road === 'cli') {
|
|
208
|
-
const out = cli(['music', 'compose_detailed', '--format', 'http', '--output-format', outputFormat, '--json', '-'], { input: JSON.stringify(body) });
|
|
209
|
-
if (out.code !== 0) throw new Error(`the render failed: ${out.stderr.trim().split('\n').slice(-3).join(' ') || out.stdout.toString('utf8').slice(0, 300)}`);
|
|
210
|
-
const h = splitHttp(out.stdout);
|
|
211
|
-
if (h.status >= 400) throw new Error(`ElevenLabs answered ${h.status}: ${h.body.toString('utf8').slice(0, 300)}`);
|
|
212
|
-
const parts = splitMultipart(h.body, h.headers['content-type']);
|
|
213
|
-
return { ...parts, songId: h.headers['song-id'] ?? null };
|
|
214
|
-
}
|
|
215
|
-
if (r.road === 'key') {
|
|
216
|
-
const res = await api('/v1/music/detailed', { method: 'POST', json: body, query: { output_format: outputFormat } });
|
|
217
|
-
return { ...splitMultipart(res.body, res.headers.get('content-type')), songId: res.headers.get('song-id') };
|
|
218
|
-
}
|
|
219
|
-
throw new Error(r.why);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Scribe (speech-to-text) with word timings. */
|
|
223
|
-
export async function transcribe(file, { model = 'scribe_v2', language = null } = {}) {
|
|
224
|
-
const r = road();
|
|
225
|
-
if (r.road === 'cli') {
|
|
226
|
-
const args = ['speech-to-text', 'convert', '--format', 'json', '--model-id', model, '--file', file];
|
|
227
|
-
if (language) args.push('--language-code', language);
|
|
228
|
-
const out = cli(args);
|
|
229
|
-
const j = parse(out.stdout);
|
|
230
|
-
if (out.code !== 0 || !j || j.error) throw new Error(`the transcription failed: ${j?.error?.message ?? out.stderr.trim().split('\n').pop()}`);
|
|
231
|
-
return j;
|
|
232
|
-
}
|
|
233
|
-
if (r.road === 'key') {
|
|
234
|
-
const form = new FormData();
|
|
235
|
-
form.set('model_id', model);
|
|
236
|
-
if (language) form.set('language_code', language);
|
|
237
|
-
form.set('file', new Blob([readFileSync(file)]), basename(file));
|
|
238
|
-
return JSON.parse((await api('/v1/speech-to-text', { method: 'POST', form })).body.toString('utf8'));
|
|
239
|
-
}
|
|
240
|
-
throw new Error(r.why);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Stem separation: a zip of stems. Priced by measurement (the account's credits before and after). */
|
|
244
|
-
export async function separateStems(file, outZip) {
|
|
245
|
-
const r = road();
|
|
246
|
-
if (r.road === 'cli') {
|
|
247
|
-
const out = cli(['music', 'separate_stems', '--file', file, '-o', outZip]);
|
|
248
|
-
if (out.code !== 0) throw new Error(`stem separation failed: ${out.stderr.trim().split('\n').pop()}`);
|
|
249
|
-
return outZip;
|
|
250
|
-
}
|
|
251
|
-
if (r.road === 'key') {
|
|
252
|
-
const form = new FormData();
|
|
253
|
-
form.set('file', new Blob([readFileSync(file)]), basename(file));
|
|
254
|
-
const res = await api('/v1/music/stem-separation', { method: 'POST', form });
|
|
255
|
-
const { writeFileSync } = await import('node:fs');
|
|
256
|
-
writeFileSync(outZip, res.body);
|
|
257
|
-
return outZip;
|
|
258
|
-
}
|
|
259
|
-
throw new Error(r.why);
|
|
260
|
-
}
|
package/lib/projects-env.mjs
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The credentials Stripe Projects keeps for a studio (`homie-studio setup --via stripe-projects`, 0.24.3), read only
|
|
3
|
-
* where they are used and never printed, returned to a person, or written anywhere else.
|
|
4
|
-
*
|
|
5
|
-
* Stripe Projects keeps them encrypted in its vault and syncs them to the project's output file (`.env` unless the
|
|
6
|
-
* environment names another), which it creates readable by this user only and git-ignores. Wrangler then deploys with
|
|
7
|
-
* the Cloudflare token from that file instead of a `wrangler login`; the music skill reads the ElevenLabs key the same
|
|
8
|
-
* way. studio.json says which (never a value): `cloudflare.auth: "stripe-projects"`, `providers.elevenlabs.via`.
|
|
9
|
-
*/
|
|
10
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
-
import { isAbsolute, join, normalize, sep } from 'node:path';
|
|
12
|
-
|
|
13
|
-
/** The output file inside the studio (`.env` by default); null for anything outside it. */
|
|
14
|
-
export function envFileOf(root, name = '.env') {
|
|
15
|
-
const n = normalize(String(name || '.env'));
|
|
16
|
-
if (isAbsolute(n) || n === '..' || n.startsWith(`..${sep}`)) return null;
|
|
17
|
-
return join(root, n);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const unquote = (v) => {
|
|
21
|
-
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v); } catch { return v.slice(1, -1); } }
|
|
22
|
-
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
|
|
23
|
-
return v;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/** A dotenv file as { NAME: value } (KEY=value lines; `export ` and comments allowed). {} when absent. */
|
|
27
|
-
export function readDotenv(file) {
|
|
28
|
-
const out = {};
|
|
29
|
-
if (!file || !existsSync(file)) return out;
|
|
30
|
-
let text = '';
|
|
31
|
-
try { text = readFileSync(file, 'utf8'); } catch { return out; }
|
|
32
|
-
for (const line of text.split(/\r?\n/)) {
|
|
33
|
-
const t = line.trim().replace(/^export\s+/, '');
|
|
34
|
-
if (!t || t.startsWith('#')) continue;
|
|
35
|
-
const i = t.indexOf('=');
|
|
36
|
-
if (i < 1) continue;
|
|
37
|
-
const k = t.slice(0, i).trim();
|
|
38
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
|
|
39
|
-
out[k] = unquote(t.slice(i + 1).trim());
|
|
40
|
-
}
|
|
41
|
-
return out;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function studioOf(root) {
|
|
45
|
-
try { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); } catch { return null; }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The environment Wrangler gets for a studio whose Cloudflare came through Stripe Projects: CLOUDFLARE_API_TOKEN and
|
|
50
|
-
* CLOUDFLARE_ACCOUNT_ID from the Projects output file. {} for every other studio (the default: `wrangler login`).
|
|
51
|
-
*/
|
|
52
|
-
export function projectsCloudflareEnv(root, studio = studioOf(root)) {
|
|
53
|
-
const cf = studio?.cloudflare;
|
|
54
|
-
if (!root || cf?.auth !== 'stripe-projects') return {};
|
|
55
|
-
const vars = readDotenv(envFileOf(root, cf.envFile));
|
|
56
|
-
return {
|
|
57
|
-
...(vars.CLOUDFLARE_API_TOKEN ? { CLOUDFLARE_API_TOKEN: vars.CLOUDFLARE_API_TOKEN } : {}),
|
|
58
|
-
...(vars.CLOUDFLARE_ACCOUNT_ID ? { CLOUDFLARE_ACCOUNT_ID: vars.CLOUDFLARE_ACCOUNT_ID } : {}),
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Whether Stripe Projects holds this studio's ElevenLabs key (studio.json `providers.elevenlabs`); never the key. */
|
|
63
|
-
export function projectsElevenLabs(root, studio = studioOf(root)) {
|
|
64
|
-
const p = studio?.providers?.elevenlabs;
|
|
65
|
-
if (!root || p?.via !== 'stripe-projects') return false;
|
|
66
|
-
const name = /^[A-Z][A-Z0-9_]{2,}$/.test(String(p.envKey ?? '')) ? p.envKey : 'ELEVENLABS_API_KEY';
|
|
67
|
-
return Boolean(readDotenv(envFileOf(root, p.envFile))[name]);
|
|
68
|
-
}
|