@homie-rocks/studio 0.49.0 → 0.50.1

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,260 @@
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
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The studio a media job works in (the folder with studio.json at or above the
3
+ * current directory), its music/ and videos/ manifests, and its money: every
4
+ * paid call is priced before it is made, checked against the budget the person
5
+ * set, and written to the studio's receipt file the moment the provider accepts it.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join, relative, resolve } from 'node:path';
10
+
11
+ export function findStudio(from = process.cwd()) {
12
+ let at = resolve(from);
13
+ for (;;) {
14
+ if (existsSync(join(at, 'studio.json'))) return at;
15
+ const up = dirname(at);
16
+ if (up === at) return null;
17
+ at = up;
18
+ }
19
+ }
20
+
21
+ export function requireStudio(from) {
22
+ const root = findStudio(from);
23
+ if (!root) throw new Error('not inside a Homie studio (no studio.json here or above). The studio-setup skill makes one.');
24
+ return root;
25
+ }
26
+
27
+ export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
28
+ export function slugify(text) {
29
+ return String(text ?? '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '') || 'untitled';
30
+ }
31
+
32
+ export function readJson(file, fallback = null) {
33
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; }
34
+ }
35
+ export function writeJson(file, value) {
36
+ mkdirSync(dirname(file), { recursive: true });
37
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
38
+ }
39
+
40
+ export function readManifest(root, kind) {
41
+ const m = readJson(join(root, kind, 'manifest.json'), null) ?? { v: 1, items: [] };
42
+ return { v: 1, ...m, items: Array.isArray(m.items) ? m.items : [] };
43
+ }
44
+
45
+ /**
46
+ * Put an entry in a manifest: replace the one with the same slug, else add it FIRST (the site shows newest first).
47
+ * A file that keeps its path keeps its R2 record (`r2`, from `homie-studio media move`): the studio checks it against
48
+ * the file's SHA-256 before it trusts it, so a re-cut at the same path is uploaded again, never served stale.
49
+ */
50
+ export function upsertEntry(root, kind, entry) {
51
+ const m = readManifest(root, kind);
52
+ const i = m.items.findIndex((x) => x?.slug === entry.slug);
53
+ if (i >= 0 && Array.isArray(entry.files)) {
54
+ const held = new Map((m.items[i].files ?? []).filter((f) => f?.path && f?.r2).map((f) => [f.path, f.r2]));
55
+ entry = { ...entry, files: entry.files.map((f) => (f?.path && held.has(f.path) && !f.r2 ? { ...f, r2: held.get(f.path) } : f)) };
56
+ }
57
+ if (i >= 0) m.items[i] = { ...m.items[i], ...entry };
58
+ else m.items.unshift(entry);
59
+ writeJson(join(root, kind, 'manifest.json'), m);
60
+ return m.items.find((x) => x.slug === entry.slug);
61
+ }
62
+
63
+ export function getEntry(root, kind, slug) {
64
+ return readManifest(root, kind).items.find((x) => x?.slug === slug) ?? null;
65
+ }
66
+
67
+ /** A path relative to the studio, with forward slashes (what the manifest records). */
68
+ export const rel = (root, abs) => relative(root, abs).split('\\').join('/');
69
+
70
+ /* ---------------------------------------------------------------- money */
71
+
72
+ /**
73
+ * The job's budget file (<job>/budget.json): { provider, unit, cap, spent, calls[] }.
74
+ * `unit` is "usd" for fal and "credits" for ElevenLabs.
75
+ */
76
+ export function readBudget(jobDir) { return readJson(join(jobDir, 'budget.json'), null); }
77
+
78
+ export function setBudget(jobDir, { provider, unit, cap, approvedBy = 'the person', note = null }) {
79
+ const prev = readBudget(jobDir);
80
+ const b = { provider, unit, cap: Number(cap), spent: prev?.spent ?? 0, approvedBy, approvedAt: new Date().toISOString(), note, calls: prev?.calls ?? [] };
81
+ writeJson(join(jobDir, 'budget.json'), b);
82
+ return b;
83
+ }
84
+
85
+ /** Refuses (throws) a call that would take the job past its cap, or any call with no budget set. */
86
+ export function checkBudget(jobDir, cost, { provider, unit }) {
87
+ const b = readBudget(jobDir);
88
+ if (!b) throw new Error(`no budget set for this job: ask the person how much to spend (${unit}) and run \`budget --cap <n>\` first`);
89
+ if (b.provider !== provider || b.unit !== unit) throw new Error(`this job's budget is for ${b.provider} in ${b.unit}, not ${provider} in ${unit}`);
90
+ if (!(Number.isFinite(cost) && cost >= 0)) throw new Error('the cost of this call is unknown, so it is not made (price it first)');
91
+ if (b.spent + cost > b.cap + 1e-9) throw new Error(`REFUSED: ${fmt(b.spent, unit)} spent + ${fmt(cost, unit)} for this call would pass the cap of ${fmt(b.cap, unit)}. Ask the person before raising it.`);
92
+ return b;
93
+ }
94
+
95
+ export const fmt = (n, unit) => (unit === 'usd' ? `US$${Number(n).toFixed(2)}` : `${Math.round(n)} ${unit}`);
96
+
97
+ /**
98
+ * One receipt line: into the job's budget (running total), the studio's receipts
99
+ * file (<kind>/receipts.jsonl), and the optional ledger named by HOMIE_SPEND_LEDGER
100
+ * (a house or team ledger; the studio's own file is always written).
101
+ */
102
+ export function receipt(root, jobDir, line) {
103
+ const at = new Date().toISOString();
104
+ const row = { schema: 'homie.media-receipt/1', at, ...line };
105
+ const b = readBudget(jobDir);
106
+ if (b) {
107
+ b.spent = +(b.spent + (Number(line.cost) || 0)).toFixed(6);
108
+ if (line.model !== 'reconcile') b.calls.push({ at, provider: line.provider, model: line.model, cost: line.cost, unit: line.unit, requestId: line.requestId ?? null, artifact: line.artifact ?? null, ...(line.pending !== undefined ? { pending: line.pending, quoted: line.quoted ?? null, measured: line.measured ?? null, balanceBefore: line.balanceBefore ?? null } : {}) });
109
+ writeJson(join(jobDir, 'budget.json'), b);
110
+ }
111
+ const kind = rel(root, jobDir).startsWith('videos/') ? 'videos' : 'music';
112
+ mkdirSync(join(root, kind), { recursive: true });
113
+ appendFileSync(join(root, kind, 'receipts.jsonl'), `${JSON.stringify(row)}\n`);
114
+ const ledger = process.env.HOMIE_SPEND_LEDGER;
115
+ if (ledger) {
116
+ try {
117
+ appendFileSync(ledger, `${JSON.stringify({
118
+ schema: 'homie.brand-spend/1', at, provider: line.provider, model: line.model, jobId: line.requestId ?? null,
119
+ units: line.units ?? null, unit: line.unitName ?? line.unit, usd: line.unit === 'usd' ? line.cost : null,
120
+ credits: line.unit === 'credits' ? line.cost : null, unpriced: null, brand: process.env.HOMIE_SPEND_BRAND ?? null,
121
+ attribution: process.env.HOMIE_SPEND_ATTRIBUTION ?? null, artifact: line.artifact ?? null,
122
+ })}\n`);
123
+ } catch (error) { process.stderr.write(`could not write the ledger ${ledger}: ${error.message}\n`); }
124
+ }
125
+ return row;
126
+ }
127
+
128
+ /** Does this studio's @homie-rocks/studio know song and video pages? (`homie-studio media list` exists.) */
129
+ export function studioHasMediaPages(root) {
130
+ const bin = join(root, 'node_modules', '.bin', 'homie-studio');
131
+ if (!existsSync(bin)) return { ok: false, why: 'the studio has no node_modules yet: run npm install in the studio' };
132
+ const r = spawnSync(bin, ['media', 'list', '--json'], { cwd: root, encoding: 'utf8', timeout: 60_000 });
133
+ // 0.18.0 and later answer with `moves` and have `media move`: big media goes to R2, checked by SHA-256, at the same address.
134
+ try { const j = JSON.parse(r.stdout); if (j.command === 'media list') return { ok: true, move: Array.isArray(j.moves) }; } catch { /* old CLI */ }
135
+ const pkg = readJson(join(root, 'node_modules', '@homie-rocks', 'studio', 'package.json'), {});
136
+ return { ok: false, why: `this studio's @homie-rocks/studio ${pkg.version ?? '(unknown)'} predates song and video pages; update it to the version that has \`homie-studio media list\`` };
137
+ }
138
+
139
+ /** Source paths for media and review jobs; built assets retain the shared games namespace. */
140
+ export const experienceDir = (root, id) => join(root, existsSync(join(root, 'apps', id, 'app.json')) ? 'apps' : 'games', id);
141
+ export const experienceJson = (dir) => join(dir, existsSync(join(dir, 'app.json')) ? 'app.json' : 'game.json');
142
+
143
+ /** Match the toolkit's rules opt-in; an older game may have an unrelated rules.ts helper. */
144
+ export function isServerRules(dir) {
145
+ const room = readJson(experienceJson(dir), {}).room;
146
+ return room != null && typeof room === 'object' && room.host !== 'browser' && existsSync(join(dir, 'src', 'rules.ts'));
147
+ }