@homie-rocks/studio 0.51.4 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +1 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/guides/app/SKILL.md +1 -1
  6. package/guides/art/SKILL.md +68 -140
  7. package/guides/art/scripts/art.mjs +6 -79
  8. package/guides/models/SKILL.md +73 -177
  9. package/guides/models/references/RIGHTS.md +1 -1
  10. package/guides/models/scripts/models.mjs +7 -288
  11. package/guides/music/SKILL.md +74 -183
  12. package/guides/music/references/COMPOSITION.md +21 -26
  13. package/guides/music/references/GAME-AUDIO.md +3 -3
  14. package/guides/music/references/RIGHTS.md +12 -15
  15. package/guides/music/scripts/music.mjs +11 -202
  16. package/guides/parts/SKILL.md +1 -1
  17. package/guides/shop/SKILL.md +68 -77
  18. package/guides/sound/SKILL.md +70 -1
  19. package/guides/sound/scripts/player/sound.js +233 -0
  20. package/guides/studio-setup/SKILL.md +11 -27
  21. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  22. package/guides/style/SKILL.md +66 -3
  23. package/guides/video/SKILL.md +83 -0
  24. package/guides/video/references/DELIVERY.md +73 -0
  25. package/guides/video/references/HONESTY.md +40 -0
  26. package/guides/video/references/METHOD.md +144 -0
  27. package/guides/video/references/RECORD.md +137 -0
  28. package/guides/video/references/TRAILER.md +154 -0
  29. package/guides/video/references/examples/site-walkthrough.json +14 -0
  30. package/guides/video/references/examples/studio-play-phone.json +15 -0
  31. package/guides/video/references/examples/studio-play.json +17 -0
  32. package/guides/video/scripts/capture-game.mjs +175 -0
  33. package/guides/video/scripts/clock.js +136 -0
  34. package/guides/video/scripts/drawover/film.js +58 -0
  35. package/guides/video/scripts/drawover/index.html +9 -0
  36. package/guides/video/scripts/drawover/look.js +115 -0
  37. package/guides/video/scripts/drawover/shots.json +12 -0
  38. package/guides/video/scripts/lib/browser.mjs +87 -0
  39. package/guides/video/scripts/lib/frames.mjs +170 -0
  40. package/guides/video/scripts/lib/remix.mjs +159 -0
  41. package/guides/video/scripts/qa.mjs +127 -0
  42. package/guides/video/scripts/record-fixed.mjs +403 -0
  43. package/guides/video/scripts/record-page.mjs +477 -0
  44. package/guides/video/scripts/tap.js +108 -0
  45. package/guides/video/scripts/video.mjs +888 -0
  46. package/lib/build.mjs +12 -9
  47. package/lib/cloudflare.mjs +6 -11
  48. package/lib/dev.mjs +1 -2
  49. package/lib/doctor.mjs +10 -54
  50. package/lib/mcp-tools.mjs +42 -21
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/media-import.mjs +41 -0
  53. package/lib/media-workflows.mjs +13 -0
  54. package/lib/official-media.mjs +15 -0
  55. package/lib/rules-build.mjs +13 -5
  56. package/lib/scaffold.mjs +2 -2
  57. package/lib/shop-links.mjs +54 -18
  58. package/lib/shop-listen.mjs +43 -0
  59. package/lib/shop-local.mjs +1 -1
  60. package/lib/shop-mcp.mjs +58 -0
  61. package/lib/stripe-connect.mjs +36 -15
  62. package/lib/template-history.json +45 -0
  63. package/lib/upgrade-media.mjs +13 -0
  64. package/lib/upgrade.mjs +10 -0
  65. package/package.json +1 -1
  66. package/parts/PARTS.md +1 -1
  67. package/rules/view.ts +4 -0
  68. package/shop/SHOP.md +66 -34
  69. package/worker/build-id.mjs +1 -1
  70. package/worker/index.mjs +5 -1
  71. package/worker/purchase-reconciliation.mjs +16 -1
  72. package/worker/shop-subscriptions.mjs +2 -0
  73. package/worker/shop.mjs +7 -5
  74. package/worker/stripe-ownership.mjs +7 -0
  75. package/worker/version.mjs +1 -1
  76. package/guides/models/references/models.json +0 -175
  77. package/guides/music/scripts/lib/eleven.mjs +0 -260
  78. package/lib/projects-env.mjs +0 -68
  79. package/lib/projects.mjs +0 -311
  80. package/lib/provider-key.mjs +0 -21
@@ -1,31 +1,11 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * music.mjs — songs and game scores for a Homie studio, made with ElevenLabs
4
- * Music through the creator's own account, mastered, looped on bar lines, and
5
- * published as a song page on the studio's site.
6
- *
7
- * check the provider road, the plan, credits left, the rights, ffmpeg, the studio
8
- * quote --seconds <s> [--vocals] the credit cost before anything is rendered
9
- * budget <slug> --cap <credits> the cap the person approved for this song (refuses anything past it)
10
- * plan <slug> --spec <spec.json> a composition plan with bar-exact sections (free, local)
11
- * render <slug> [--dry-run] [--yes] ONE paid render of the plan (or --prompt/--seconds); --dry-run sends nothing
12
- * lyrics <slug> [--render <n>] the transcription check: is every planned line actually sung?
13
- * reconcile <slug> settle receipts the account had not reported yet (its balance can lag)
14
- * master <slug> [--lufs -14] [--tp -1] two-pass loudness to a target, measured after
15
- * loop <slug> --bars <n> [--from-bar <k>] a seamless loop cut on bar lines (WAV + OGG), seam measured
16
- * stems <slug> [--yes] stems from the provider (paid; priced by measurement)
17
- * analyze <file> duration, loudness, tempo and beat grid of any audio file
18
- * add <slug> --title "<t>" [--blurb] [--kind song|score|loop] [--for-game <id>] [--publish]
19
- * the entry in music/manifest.json from what is in music/<slug>/
20
- * publish <slug> media to R2 when the studio has it, rebuild and redeploy, check the page
21
- *
22
- * Everything prints a few lines; --json prints the result. Nothing prints a key.
23
- */
2
+ /** music.mjs: local asset preparation only. Generation, schemas and pricing belong to the official provider MCP.
3
+ * Read ../SKILL.md for the current workflow. */
24
4
  import { spawnSync } from 'node:child_process';
25
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
26
6
  import { join, resolve } from 'node:path';
27
7
  import { beatsOf, decodeMono, loudness, need, probe, run } from './lib/audio.mjs';
28
- import { composeDetailed, dryRun, quoteCredits, rightsFor, road, separateStems, subscription, transcribe } from './lib/eleven.mjs';
8
+
29
9
  import { SLUG, checkBudget, getEntry, readBudget, readJson, receipt, rel, requireStudio, setBudget, studioHasMediaPages, upsertEntry, writeJson } from './lib/studio.mjs';
30
10
 
31
11
  const argv = process.argv.slice(2);
@@ -55,12 +35,7 @@ function jobDir(root, slug) {
55
35
  /* ---------------------------------------------------------------- check / quote / budget */
56
36
 
57
37
  async function check() {
58
- const r = road();
59
- const out = { ok: true, command: 'check', road: r.road, cli: r.cli ?? null, ffmpeg: need('ffmpeg') && need('ffprobe') };
60
- if (!r.road) { out.ok = false; out.why = r.why; }
61
- else {
62
- try { const s = await subscription(r); out.plan = s; out.rights = rightsFor(s.tier); } catch (error) { out.ok = false; out.why = error.message; }
63
- }
38
+ const out = { ok: true, command: 'check', ffmpeg: need('ffmpeg') && need('ffprobe'), generation: 'Use the official ElevenLabs MCP in your AI client' };
64
39
  let root = null;
65
40
  try { root = requireStudio(); } catch (error) { out.studio = { ok: false, why: error.message }; }
66
41
  if (root) out.studio = { ok: true, root, pages: studioHasMediaPages(root) };
@@ -68,18 +43,6 @@ async function check() {
68
43
  return out;
69
44
  }
70
45
 
71
- async function quote() {
72
- const seconds = Number(flags.get('seconds'));
73
- if (!(seconds >= 3 && seconds <= 600)) throw new Error('--seconds must be 3..600 (ElevenLabs Music renders 3 s to 10 min)');
74
- const q = quoteCredits(seconds, { vocals: flags.has('vocals') });
75
- let plan = null;
76
- try { plan = await subscription(); } catch { /* quote without the account */ }
77
- const fits = plan ? q.total <= plan.remaining : null;
78
- const cap = flags.has('budget') ? Number(flags.get('budget')) : null;
79
- const longest = cap ? Math.floor(cap / (q.total / seconds)) : null;
80
- return { ok: true, command: 'quote', seconds, credits: q, plan, fitsInPlan: fits, budget: cap, longestWithinBudget: longest };
81
- }
82
-
83
46
  function budget(root) {
84
47
  const slug = pos[1];
85
48
  const cap = Number(flags.get('cap'));
@@ -143,7 +106,7 @@ function plan(root) {
143
106
  if (specFile !== join(dir, 'spec.json')) writeJson(join(dir, 'spec.json'), spec);
144
107
  const p = buildPlan(spec);
145
108
  writeJson(join(dir, 'plan.json'), { title: spec.title ?? slug, bpm: p.bpm, key: spec.key ?? null, beatsPerBar: p.beatsPerBar, bars: p.bars, totalMs: p.totalMs, vocals: p.vocals, lyrics: p.lyrics, composition_plan: p.plan });
146
- const q = quoteCredits(p.totalMs / 1000, { vocals: p.vocals });
109
+ const q = { status: 'unknown', next: 'Quote through the official ElevenLabs MCP before generation' };
147
110
  return { ok: true, command: 'plan', slug, seconds: p.totalMs / 1000, bars: p.bars, bpm: p.bpm, vocals: p.vocals, sections: p.plan.chunks.map((c) => ({ text: c.text.split('\n')[0], ms: c.duration_ms })), warnings: p.warnings, quote: q, file: rel(root, join(dir, 'plan.json')) };
148
111
  }
149
112
 
@@ -154,76 +117,9 @@ function plan(root) {
154
117
  * +445 a minute later). Wait up to ~40 s for it to move; null when it has not (the receipt then holds
155
118
  * the quote and is marked pending, and `reconcile` settles it later).
156
119
  */
157
- async function settle(r, beforeUsed) {
158
- for (let i = 0; i < 10; i++) {
159
- const s = await subscription(r).catch(() => null);
160
- if (s && s.used > beforeUsed) return { delta: s.used - beforeUsed, after: s };
161
- await new Promise((ok) => setTimeout(ok, Number(process.env.MUSIC_SETTLE_MS ?? 4000)));
162
- }
163
- return { delta: null, after: await subscription(r).catch(() => null) };
164
- }
165
120
 
166
- function nextRender(dir) {
167
- const ns = readdirSync(join(dir, 'work')).map((f) => /^render-(\d+)\./.exec(f)?.[1]).filter(Boolean).map(Number);
168
- return ns.length ? Math.max(...ns) + 1 : 1;
169
- }
170
121
 
171
- async function render(root) {
172
- const slug = pos[1];
173
- const dir = jobDir(root, slug);
174
- const p = readJson(join(dir, 'plan.json'), null);
175
- let body; let seconds; let vocals = false;
176
- if (flags.has('prompt')) {
177
- seconds = Number(flags.get('seconds'));
178
- if (!(seconds >= 3 && seconds <= 600)) throw new Error('--seconds 3..600 with --prompt');
179
- if (!flags.has('instrumental')) throw new Error('a prompt-only render is for instrumentals (--instrumental): words go through a composition plan (`plan`), because a prompt with lyrics has come back with nothing sung');
180
- body = { prompt: String(flags.get('prompt')), music_length_ms: Math.round(seconds * 1000), force_instrumental: true, model_id: 'music_v2' };
181
- } else {
182
- if (!p) throw new Error(`no plan for ${slug}: run \`plan ${slug} --spec <spec.json>\` first (or --prompt ... --instrumental --seconds n)`);
183
- seconds = p.totalMs / 1000; vocals = p.vocals;
184
- body = { model_id: 'music_v2', composition_plan: p.composition_plan, with_timestamps: true };
185
- }
186
- const q = quoteCredits(seconds, { vocals });
187
- if (flags.has('dry-run')) {
188
- const d = dryRun(body, { outputFormat: OUTPUT_FORMAT });
189
- return { ok: d.ok, command: 'render', dryRun: true, slug, seconds, quote: q, ...d };
190
- }
191
- const b = checkBudget(dir, q.music, { provider: 'elevenlabs', unit: 'credits' });
192
- if (!flags.has('yes')) {
193
- return { ok: false, command: 'render', slug, needs: 'approval', seconds, quote: q, budget: { cap: b.cap, spent: b.spent }, why: `This render costs about ${q.music} credits (${q.basis}). Tell the person that, and render with --yes once they say go.` };
194
- }
195
- const n = nextRender(dir);
196
- const pending = join(dir, 'work', `render-${n}.pending`);
197
- const stale = readdirSync(join(dir, 'work')).find((f) => f.endsWith('.pending'));
198
- if (stale && !flags.has('again')) throw new Error(`work/${stale} says an earlier render was sent and never came back; it may have been billed. Check the account's history, then run again with --again if the person agrees`);
199
- if (stale) rmSync(join(dir, 'work', stale));
200
- const r = road();
201
- const before = await subscription(r);
202
- if (before.remaining < q.music) throw new Error(`the account has ${before.remaining} credits left and this needs about ${q.music}; nothing was sent (ElevenLabs does not go over on this plan: ${before.canGoOver ? 'it can' : 'it cannot'})`);
203
- writeJson(pending, { at: new Date().toISOString(), seconds, quote: q.music });
204
- say(`rendering ${seconds} s (about ${q.music} credits)…`);
205
- const started = Date.now();
206
- const res = await composeDetailed(body, { outputFormat: OUTPUT_FORMAT });
207
- rmSync(pending, { force: true });
208
- if (!res.audio?.length) throw new Error('the answer had no audio in it');
209
- const audio = join(dir, 'work', `render-${n}.mp3`);
210
- writeFileSync(audio, res.audio);
211
- writeJson(join(dir, 'work', `render-${n}.json`), { songId: res.songId, composition_plan: res.json?.composition_plan ?? null, song_metadata: res.json?.song_metadata ?? null, words: res.json?.words_timestamps ?? null });
212
- const { delta: measured, after } = await settle(r, before.used);
213
- const facts = probe(audio);
214
- const rights = rightsFor(before.tier);
215
- // The budget counts the larger of the quote and the measurement until the account has reported the real cost.
216
- const line = receipt(root, dir, {
217
- provider: 'elevenlabs', model: 'music_v2', requestId: res.songId, cost: measured ?? q.music, unit: 'credits', pending: measured === null,
218
- quoted: q.music, measured, balanceBefore: before.used, seconds: facts.duration, artifact: rel(root, audio), plan: before.tier, rights,
219
- });
220
- const out = { n, file: rel(root, audio), seconds: facts.duration, askedSeconds: seconds, songId: res.songId, words: res.json?.words_timestamps?.length ?? 0, creditsQuoted: q.music, creditsMeasured: measured, creditsLeft: after?.remaining ?? null, tier: before.tier, rights, ms: Date.now() - started, receiptAt: line.at };
221
- writeJson(join(dir, 'render.json'), { ...out, at: new Date().toISOString() });
222
- const note = measured === null
223
- ? 'the account had not reported this render\'s cost yet: the quote is counted against the budget; run `reconcile` in a minute for the real number'
224
- : Math.abs(measured - q.music) > q.music * 0.5 ? 'the measured cost is far from the quote: another job on the same account may have spent at the same time' : undefined;
225
- return { ok: true, command: 'render', slug, ...out, note };
226
- }
122
+
227
123
 
228
124
  /* ---------------------------------------------------------------- lyrics check */
229
125
 
@@ -264,32 +160,6 @@ export function matchLyrics(lines, heardWords, { pass = 0.75 } = {}) {
264
160
  return rows;
265
161
  }
266
162
 
267
- async function lyricsCheck(root) {
268
- const slug = pos[1];
269
- const dir = jobDir(root, slug);
270
- const p = readJson(join(dir, 'plan.json'), null);
271
- if (!p?.vocals) throw new Error('this song has no sung lines in its plan');
272
- const last = readJson(join(dir, 'render.json'), null);
273
- const n = flags.has('render') ? Number(flags.get('render')) : last?.n;
274
- const file = flags.get('file') ? resolve(String(flags.get('file'))) : join(dir, 'work', `render-${n}.mp3`);
275
- if (!existsSync(file)) throw new Error(`no audio at ${rel(root, file)}`);
276
- const seconds = probe(file).duration;
277
- const cost = Math.ceil(seconds * 0.5);
278
- const b = readBudget(dir);
279
- if (b) checkBudget(dir, cost, { provider: 'elevenlabs', unit: 'credits' });
280
- const r = road();
281
- const before = await subscription(r).catch(() => null);
282
- const t = await transcribe(file, { language: flags.get('language') ?? null });
283
- const measured = before ? (await settle(r, before.used)).delta : null;
284
- receipt(root, dir, { provider: 'elevenlabs', model: 'scribe_v2', requestId: t.transcription_id ?? null, cost: measured ?? cost, unit: 'credits', pending: measured === null, quoted: cost, measured, balanceBefore: before?.used ?? null, seconds, artifact: rel(root, file) });
285
- const words = (t.words ?? []).filter((w) => (w.type ?? 'word') === 'word');
286
- const lines = p.lyrics.flatMap((s) => s.lines);
287
- const rows = matchLyrics(lines, words);
288
- const out = { ok: rows.every((x) => x.ok), command: 'lyrics', slug, file: rel(root, file), heard: t.text ?? '', lines: rows, missing: rows.filter((x) => !x.ok).map((x) => x.line), creditsMeasured: measured };
289
- writeJson(join(dir, `lyrics-check-${n ?? 'file'}.json`), out);
290
- return out;
291
- }
292
-
293
163
  /* ---------------------------------------------------------------- master */
294
164
 
295
165
  function master(root) {
@@ -396,58 +266,13 @@ function loop(root) {
396
266
 
397
267
  /* ---------------------------------------------------------------- stems */
398
268
 
399
- async function stems(root) {
400
- const slug = pos[1];
401
- const dir = jobDir(root, slug);
402
- const src = flags.get('in') ? resolve(String(flags.get('in'))) : join(dir, `${slug}-master.wav`);
403
- if (!existsSync(src)) throw new Error('stems of what? master first, or --in <file>');
404
- const est = Math.ceil(probe(src).duration * 10);
405
- checkBudget(dir, est, { provider: 'elevenlabs', unit: 'credits' });
406
- if (!flags.has('yes')) return { ok: false, command: 'stems', needs: 'approval', why: `Stem separation is paid and its price is not published per second; this job's budget allows it (a guess of ${est} credits is held against the cap). Ask the person, then run with --yes.` };
407
- const r = road();
408
- const before = await subscription(r);
409
- const zip = join(dir, 'work', 'stems.zip');
410
- await separateStems(src, zip);
411
- const after = await subscription(r).catch(() => null);
412
- const measured = after ? after.used - before.used : null;
413
- receipt(root, dir, { provider: 'elevenlabs', model: 'stem-separation', cost: measured ?? est, unit: 'credits', estimated: measured === null, artifact: rel(root, zip) });
414
- const outDir = join(dir, 'stems');
415
- mkdirSync(outDir, { recursive: true });
416
- const u = spawnSync('unzip', ['-o', '-q', zip, '-d', outDir]);
417
- if (u.status !== 0) throw new Error('could not unzip the stems');
418
- return { ok: true, command: 'stems', slug, files: readdirSync(outDir).map((f) => rel(root, join(outDir, f))), creditsMeasured: measured };
419
- }
420
-
421
269
  /* ---------------------------------------------------------------- reconcile */
422
270
 
423
271
  /**
424
272
  * Settle pending receipts: the account's usage now, minus its usage before this song's first pending call,
425
273
  * is what those calls cost (plus anything else the account did meanwhile, which is said, not hidden).
426
274
  */
427
- async function reconcile(root) {
428
- const slug = pos[1];
429
- const dir = jobDir(root, slug);
430
- const b = readJson(join(dir, 'budget.json'), null);
431
- if (!b) throw new Error('this song has no budget.json');
432
- const pendingIdx = b.calls.map((c, i) => (c.pending ? i : -1)).filter((i) => i >= 0);
433
- const now = await subscription();
434
- if (!pendingIdx.length) return { ok: true, command: 'reconcile', slug, pending: 0, spent: b.spent, creditsLeft: now.remaining };
435
- const first = b.calls[pendingIdx[0]];
436
- if (first.balanceBefore == null) throw new Error('the pending call has no balance reading to settle from');
437
- const settledBefore = b.calls.slice(pendingIdx[0]).filter((c) => !c.pending).reduce((a, c) => a + (c.cost || 0), 0);
438
- const delta = now.used - first.balanceBefore - settledBefore;
439
- const quoted = pendingIdx.reduce((a, i) => a + (b.calls[i].quoted ?? b.calls[i].cost), 0);
440
- if (delta <= 0) return { ok: false, command: 'reconcile', slug, why: 'the account still shows no change since the render; try again in a minute' };
441
- for (const i of pendingIdx) {
442
- const c = b.calls[i];
443
- const share = Math.round(delta * ((c.quoted ?? c.cost) / quoted));
444
- c.measured = share; c.cost = share; c.pending = false; c.reconciledAt = new Date().toISOString();
445
- }
446
- b.spent = b.calls.reduce((a, c) => a + (c.cost || 0), 0);
447
- writeJson(join(dir, 'budget.json'), b);
448
- receipt(root, dir, { provider: 'elevenlabs', model: 'reconcile', cost: 0, unit: 'credits', note: `settled ${pendingIdx.length} pending call(s): ${delta} credits measured against ${quoted} quoted (anything else the account did in between is included)`, measuredTotal: delta });
449
- return { ok: true, command: 'reconcile', slug, settled: pendingIdx.length, measured: delta, quoted, spent: b.spent, cap: b.cap, creditsLeft: now.remaining };
450
- }
275
+
451
276
 
452
277
  /* ---------------------------------------------------------------- analyze */
453
278
 
@@ -573,27 +398,16 @@ function print(r) {
573
398
  const L = [];
574
399
  switch (r.command) {
575
400
  case 'check':
576
- L.push(`ElevenLabs: ${r.road ? `connected (${r.road === 'cli' ? `the elevenlabs CLI, ${r.cli}` : r.via === 'stripe-projects' ? 'the key Stripe Projects keeps for this studio' : r.source ?? 'ELEVENLABS_API_KEY'})` : 'not connected'}`);
577
401
  if (r.plan) L.push(`Plan: ${r.plan.tier} (${r.plan.status}); ${r.plan.remaining} credits left of ${r.plan.limit}; resets ${r.plan.resetAt ?? '?'}; ${r.plan.canGoOver ? 'CAN go over (usage-based billing)' : 'cannot go over'}`);
578
402
  if (r.rights) L.push(`Rights: ${r.rights.words}`);
579
403
  L.push(`ffmpeg: ${r.ffmpeg ? 'yes' : 'NO'}`, `Studio: ${r.studio?.ok ? r.studio.root : r.studio?.why}${r.studio?.pages && !r.studio.pages.ok ? ` (song pages: ${r.studio.pages.why})` : ''}`);
580
404
  break;
581
- case 'quote':
582
- L.push(`${r.seconds} s: about ${r.credits.total} credits (${r.credits.music} music${r.credits.lyricCheck ? ` + ${r.credits.lyricCheck} lyric check` : ''}); ${r.credits.basis}.`);
583
- if (r.plan) L.push(`The account has ${r.plan.remaining} credits left (${r.plan.tier}).`);
584
- if (r.budget) L.push(`Within a budget of ${r.budget} credits the longest render is about ${r.longestWithinBudget} s.`);
585
- break;
405
+
586
406
  case 'plan':
587
- L.push(`Plan: ${r.seconds} s, ${r.bars} bars at ${r.bpm} BPM${r.vocals ? ', with vocals' : ', instrumental'}; about ${r.quote.total} credits.`, ...r.sections.map((s) => ` ${s.text} ${s.ms} ms`), ...r.warnings.map((w) => ` warning: ${w}`), `Wrote ${r.file}`);
588
- break;
589
- case 'render':
590
- if (r.dryRun) L.push(`Dry run (nothing sent): ${r.ok ? 'the request is valid' : r.why}; it would cost about ${r.quote.music} credits.`);
591
- else if (r.needs) L.push(r.why);
592
- else L.push(`Rendered ${r.file}: ${r.seconds.toFixed(2)} s (asked ${r.askedSeconds} s). Credits: quoted ${r.creditsQuoted}, measured ${r.creditsMeasured ?? 'unknown'}; ${r.creditsLeft ?? '?'} left. Plan ${r.tier}: ${r.rights.commercial ? 'commercial licence' : 'NO commercial licence'}.`, ...(r.note ? [r.note] : []));
593
- break;
594
- case 'lyrics':
595
- L.push(`${r.ok ? 'Every planned line is sung.' : `NOT every line is sung: ${r.missing.length} missing.`}`, ...r.lines.map((x) => ` ${x.ok ? 'ok ' : 'MISS'} ${Math.round(x.matched * 100)}% "${x.line}" heard: "${x.heard}"`));
407
+ L.push(`Plan: ${r.seconds} s, ${r.bars} bars at ${r.bpm} BPM${r.vocals ? ', with vocals' : ', instrumental'}; quote through the official ElevenLabs MCP.`, ...r.sections.map((s) => ` ${s.text} ${s.ms} ms`), ...r.warnings.map((w) => ` warning: ${w}`), `Wrote ${r.file}`);
596
408
  break;
409
+
410
+
597
411
  case 'master':
598
412
  L.push(`Mastered to ${r.master.lufs} LUFS, ${r.master.truePeak} dBTP (target ${r.target.lufs} / ${r.target.truePeak}); the mp3 reads ${r.mp3.lufs} LUFS, ${r.mp3.truePeak} dBTP. ${r.master.file}, ${r.mp3.file}`);
599
413
  break;
@@ -616,17 +430,12 @@ async function main() {
616
430
  return null;
617
431
  }
618
432
  if (cmd === 'check') return check();
619
- if (cmd === 'quote') return quote();
620
433
  if (cmd === 'analyze') return analyze();
621
434
  const root = requireStudio();
622
435
  if (cmd === 'budget') return budget(root);
623
436
  if (cmd === 'plan') return plan(root);
624
- if (cmd === 'render') return render(root);
625
- if (cmd === 'lyrics') return lyricsCheck(root);
626
- if (cmd === 'reconcile') return reconcile(root);
627
437
  if (cmd === 'master') return master(root);
628
438
  if (cmd === 'loop') return loop(root);
629
- if (cmd === 'stems') return stems(root);
630
439
  if (cmd === 'add') return add(root);
631
440
  if (cmd === 'publish') return publishEntry(root, 'music', pos[1], { deploy: !flags.has('no-deploy') });
632
441
  return { ok: false, command: cmd, why: `unknown command "${cmd}" (music.mjs help)` };
@@ -155,4 +155,4 @@ payer after the purchase, then record its hash with `shop refund <order> --manua
155
155
 
156
156
  For app, music and video parts, use `part_new` without `from`, choose any suitable kind label, put the licensed files in its folder, and supply the source as `{kind, id, studio}` in `from`. Preserve existing game manifests and imports. Hash and share through the usual tools. No new app build framework is implied.
157
157
 
158
- Selling defaults to the keyless shop: `shop connect` uses the official Stripe CLI approval, creates Payment Links and the webhook, and installs the signing key privately. One approval if needed, zero for an already connected profile. Machine MPP/SPT and MCP charges and the Stripe-recorded x402 path need the fuller Worker credential. Read the payment-path table in PARTS.md before setup. With browser access, operate Stripe's key page for the owner, let them complete only provider verification prompts, click Copy, and run `shop connect --from-clipboard`. Never ask the owner to find or paste a key when this alternative is available. The command reads and clears the clipboard privately. Do not print its contents. No real-account verification is claimed by the release's stand-in tests.
158
+ Selling defaults to Stripe’s official plugin for shop catalog and links, with Homie’s private webhook installation. Read the shop skill. `shop connect` remains the CLI alternative for priced tool links. Never ask an owner for an API key. Machine payment paths that require runtime credentials are developer integrations, not an owner setup fallback.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: shop
3
3
  description: Sell things in a Homie studio's games with the studio's OWN Stripe - a supporter pack, cosmetics, a season pass, a one-time unlock, a tip, a round of several items to a table, a paid booking with slot capacity, monthly giving or a membership - in real money, with Stripe Checkout, Stripe Tax or Stripe Managed Payments, refunds from the office, and optional studio policies with an open default; set up with Stripe's own agent tools (Stripe's MCP server and skills) with one browser approval and Payment Links by default, no human-handled key and no API key in the Worker. Use when someone asks to sell something, add a shop or store, take payments or donations, take paid bookings, set up monthly giving or memberships, make money from a game, set up or connect Stripe, make the products in Stripe, asks "how are sales?", about tax or Managed Payments, to add a supporter badge, refund a player, or about chargebacks.
4
- compatibility: The studio's own current toolkit with stripe_login or shop connect --renew, Wrangler, and the official Stripe CLI. Stripe's own agent plugin (MCP and skills) is optional for catalog work and analytics.
4
+ compatibility: Stripe's official OAuth MCP/plugin, Homie's stripe_shop_plan and stripe_webhook tools, and Wrangler. Stripe CLI is used privately for webhook setup and local forwarding.
5
5
  metadata:
6
6
  providers: stripe
7
7
  ---
@@ -16,7 +16,7 @@ Studio functions (`functions/FUNCTIONS.md`), with the event ID as the idempotenc
16
16
 
17
17
  # The shop
18
18
 
19
- Check that the toolkit exposes `stripe_login`, or that `homie-studio --help` lists `shop connect` with `--renew` and `--manual`. An older toolkit's default is the paste page: upgrade before using this flow; do not send the owner to that old default.
19
+ Check that the toolkit exposes `stripe_shop_plan` and `stripe_webhook`; upgrade older toolkits first.
20
20
  The guide is `node_modules/@homie-rocks/studio/shop/SHOP.md`; the owner's plain words are `SELLING.md`.
21
21
 
22
22
  **The model, said plainly to the person.** The studio sells with **its own Stripe account**. The studio is the
@@ -28,87 +28,78 @@ entity field or a command into proof of a purchase: grants still come from the s
28
28
  verified server records. Room state is replicated to players; keep payment and private
29
29
  customer data out of it. Read the game skill's RULES.md before changing game outcomes.
30
30
 
31
- ## Connect with Stripe's own browser approval
32
-
33
- Use `stripe_login` (MCP) or `npx --no-install homie-studio shop connect`. Test mode and a
34
- sandbox first. If the CLI is missing, run the one installation command the result gives:
35
- `npm install -g @stripe/cli@latest`. The toolkit opens Stripe's page and completes its official
36
- non-interactive login. Give the person the pairing code; they select their own account and approve.
37
- Use `studio_job` for the background result. Do not call a raw CLI config command: it may print secrets.
38
-
39
- The default is **keyless**: the toolkit uses the CLI's approved account to sync Products, Prices
40
- (including custom amounts for tips), Payment Links and the webhook. It captures the signing secret
41
- privately and installs it with the link configuration; it never exports the CLI credentials.
42
- The Worker redirects buyers to Stripe and grants purchases from signed events. No desktop agent,
43
- Homie service or API key is needed when someone pays. Run connect again after editing shop.json;
44
- it updates changed objects and archives removed ones. Existing connections need no new approval.
45
-
46
- Keyless also supports mixed fixed-price menus, capacity-limited booking links and fixed
47
- monthly gifts/memberships. Read SHOP.md's 0.51 examples before implementing these.
48
- Guests choose menu quantities on Stripe. Booking links show pending without holding
49
- an unpaid seat; Stripe's completion limit is the hard capacity. Use
50
- `stripe_bookings_sync` after publishing/changing slots (complete manifest), or update
51
- `.studio/booking-slots.json` and rerun connect. Sync creates the no-code portal login
52
- through the API; no Dashboard toggle is needed. Never ask for a key.
53
-
54
- Only when explicitly selected, an existing Worker key plus
55
- `STRIPE_CHECKOUT_MODE=sessions` enables exact app carts, exclusive expiring holds,
56
- spending reservations, provider reads and runtime refunds. A key alone never selects
57
- it. Default connect selects links and removes the runtime key.
58
-
59
- ## Never
60
-
61
- - Never ask a person for a Stripe key, a webhook secret or a password in chat. Never display a CLI
62
- config, credential response, environment value or raw login-completion output. The toolkit captures
63
- these privately. Keys belong to the studio; no Homie account or service takes payments.
64
- - **Never make a webhook endpoint or an event destination through Stripe's MCP**: its signing secret
65
- would enter the conversation. `shop connect` handles endpoint creation and secret storage privately.
66
- - Never silently select `--manual`. Only when the owner chooses the fallback, run
67
- `shop connect --manual` (or `--manual --live`) and give the local page link. The page explains the
68
- remaining Dashboard restricted-key step and creates the webhook automatically. This fallback
69
- requires copying a key and does not meet the one-approval target; say so plainly.
70
- - Live mode only when the owner says go live. Account activation, tax, bank details and Stripe
71
- approval links are the owner's. Never approve a Stripe confirmation link for them.
72
- - The studio chooses its settings. Omitted policies are open; choose a preset only when requested.
73
-
74
- ## The owner's steps, for a creator with a fresh Stripe account
75
-
76
- With an existing Stripe account and deployed studio, there is **one Stripe approval** for the CLI;
77
- if that CLI profile is already connected, there are **zero new approvals**. An existing MCP-only
78
- consent is not a CLI login and is not exported or borrowed. Account creation/activation, an administrator
79
- enabling CLI access, and an initial Cloudflare sign-in are separate Stripe/hosting requirements.
80
-
81
- | The owner | You |
82
- |---|---|
83
- | Approves Stripe's browser page, selecting their studio account and sandbox. | Create/check shop.json; run stripe_login / shop connect; follow studio_job to completion. |
84
- | Nothing. | Sync all items and the webhook, install the signing secret and links privately, then verify a test purchase and grant. |
85
- | Requests a refund in Stripe or asks their connected AI. | Use the official Stripe connection, honor any confirmation link, verify the signed refund event removes the item. |
86
- | Says “go live” and completes Stripe activation if needed. | shop connect --live in the authorized account; verify only an authorized real purchase. |
87
-
88
- `shop connect --renew` restarts the approval flow when needed. CLI OAuth refresh is the CLI's job;
89
- there is no runtime API credential to expire. `shop catalog` remains the optional older catalog planner
90
- for keyed shops; keyless shops use connect for the complete catalog, links and webhook sync.
91
-
92
- ## Optional Stripe agent tools
93
-
94
- For catalog work, analytics and advice, `stripe agent setup` installs Stripe's own agent plugin and
95
- skills; its OAuth MCP is `https://mcp.stripe.com`. This is optional and separate from Worker credentials.
96
- Check `get_stripe_account_info` before writes; confirm the account and mode match the studio.
97
- For CLI refunds or event resends, use the non-secret `profile` returned by connect with `stripe --project-name <profile>`; preserve its test/live context. Do not read credentials.
98
- The tools include `stripe_api_read`, `stripe_api_write`, API discovery and `stripe_analytics`.
99
- From **2026-10-31**, MCP rejects API keys without the Agent tag; OAuth remains supported. Agent keys
100
- can put refunds behind Stripe approval. A returned confirmation link belongs to the owner; wait
101
- for approval before retrying. Do not silently replace the payment runtime with MCP; a separate persistent MCP client would need its own production verification.
31
+ ## Default: Stripe's official plugin
32
+
33
+ Use Stripe's official plugin, authenticated with its browser OAuth approval, for Products,
34
+ Prices, Payment Links, menu links, per-slot booking links, monthly giving and the customer
35
+ portal. Install with `stripe agent setup` if needed; the MCP URL is
36
+ `https://mcp.stripe.com`. Current tools are `stripe_api_search`, `stripe_api_details`,
37
+ `stripe_api_read` and `stripe_api_write`. Discover the operation and its schema before
38
+ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
39
+
40
+ 1. Create/check `shop.json`. Read the app's published slot records and supply the complete
41
+ slot manifest described below. Select the owner's account and sandbox in Stripe's plugin;
42
+ read `/v1/account` to record its account ID. Live mode only after the owner requests it.
43
+ 2. Call Homie's `stripe_shop_plan` with `account`, `mode: "test"`, `slots` and
44
+ `observations: []`. CLI equivalent: `homie-studio shop plan --have plan-input.json`.
45
+ That JSON file contains the same fields. A returned `next` is the exact operation:
46
+ method, API path, parameters, API version and (for creates) idempotency key. Updates set desired fields; read before retrying. Metadata
47
+ scopes objects to this studio and origin; prices and links have content revisions.
48
+ 3. Perform each `next` through Stripe's official MCP. For GET, append
49
+ `{ "path": "…", "params": {…}, "body": <Stripe response> }` to `observations`
50
+ and plan again. Follow every pagination request. After a write, discard observations
51
+ and read fresh. Replanning resolves real Stripe IDs; never invent IDs or substitute
52
+ a different price. Reuse the supplied idempotency key where the tool supports it;
53
+ otherwise read before retrying and match the stable product ID/revision metadata.
54
+ 4. When no writes remain, use fresh reads with `stripe_shop_plan` `verify: true`
55
+ (CLI `homie-studio shop verify --have plan-input.json`). It checks the catalog,
56
+ actual link fields and line items, menus, slot limits and portal settings against
57
+ shop.json and the slot manifest. A mismatch blocks verification. It saves only public
58
+ configuration to `.studio/stripe-mcp.json`; local test configuration goes to `.dev.vars`.
59
+ 5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
60
+ Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
61
+ checks the same account as the MCP, and installs the signing secret and verified
62
+ links through Wrangler stdin. The CLI needs one separate browser approval if its
63
+ studio profile is not already connected. MCP approval cannot be reused or exported.
64
+ Cloudflare authorization and an existing deployment are prerequisites.
65
+ 6. After installation, perform the returned `retire` operations through Stripe MCP to
66
+ archive replaced catalog objects, then verify fresh reads again. Test a sandbox
67
+ purchase and its signed event, order grant, booking capacity or recurring invoice;
68
+ a browser return is never proof. Stripe CLI `trigger` can send a test event, and
69
+ `events resend <event> --webhook-endpoint <endpoint>` retries a delivery. Check the
70
+ endpoint's delivery result and the app's verified records. Do not type a card into
71
+ a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
72
+
73
+ Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
74
+
75
+ The owner never handles an API key or signing secret. No Homie backend handles payments.
76
+ The Worker needs only public link configuration and the signing secret; buyers pay
77
+ Stripe directly even when the AI and the owner's computer are offline. The old
78
+ `stripe_login` / `shop connect` flow remains an alternative that syncs everything
79
+ through Stripe CLI browser approval. `--renew` renews that approval; `--live` requires
80
+ the owner's go-live request. Never read CLI config or print login-completion output.
81
+
82
+ For local checkout, pass `url: "http://localhost:8787"` (or the private Wi-Fi address)
83
+ and `mode: "test"` to the same plan/verify flow; the official plugin makes the links.
84
+ Run `homie-studio shop listen --profile <sandbox-profile>` in a background terminal.
85
+ Stripe CLI is required for forwarding; one browser approval is enough if needed.
86
+ The command captures and saves the listener secret privately. Start/restart
87
+ `homie-studio dev --lan` once it reports ready and keep the listener running.
88
+ Never copy the listener's raw output into the conversation.
89
+
90
+ For a faster local iteration, `homie-studio build --quick` skips generated rules play
91
+ and the snapshot smoke-run, while retaining compilation, guards and type checks.
92
+ Run a full build before release; quick output is not evidence that play checks passed.
102
93
 
103
94
  ## Do
104
95
 
105
96
  | The person says | Run | What happens |
106
97
  |---|---|---|
107
98
  | "Sell a supporter pack for $5" | `npx --no-install homie-studio shop init --supporter` (then `shop check`) | `shop.json` with a US$5 Supporter pack (a badge on their profile and beside their name in rooms, for a year; it changes nothing about play) and `SELLING.md`. Commit both. |
108
- | "Sell a skin / a season pass / the full game" | edit `shop.json` `items` (`kind`: `cosmetic`, `pass`, `unlock`; `price` in Stripe currency units; `gives`: the keys the game reads), `shop check`, then `shop connect` again | Kinds are studio labels, without a fixed list. `"advantage": true` follows the studio policy for beginner and kids servers. Homie sets no price ceiling. |
99
+ | "Sell a skin / a season pass / the full game" | edit `shop.json` `items` (`kind`: `cosmetic`, `pass`, `unlock`; `price` in Stripe currency units; `gives`: the keys the game reads), `shop check`, then the official MCP plan/verify flow again | Kinds are studio labels, without a fixed list. `"advantage": true` follows the studio policy for beginner and kids servers. Homie sets no price ceiling. |
109
100
  | "Let people tip" | an item `{ "kind": "tip", "price": "choose", "min": 200, "max": 5000 }` | Pay what you want; amounts follow Stripe currency requirements, and max is optional and chosen by the studio. |
110
- | "Make the products in Stripe" | `shop connect` | Idempotent Product, Price, Payment Link and webhook sync from shop.json; no secret enters tool output. |
111
- | "Connect my Stripe" / "turn the shop on" | `stripe_login` or `shop connect` | Approve Stripe once if needed; the Worker receives links and the signing secret only. Follow the single next step returned. Live only when requested. |
101
+ | "Make the products in Stripe" | `stripe_shop_plan` | Idempotent Product, Price, Payment Link and webhook sync from shop.json; no secret enters tool output. |
102
+ | "Connect my Stripe" / "turn the shop on" | `stripe_shop_plan`, then `stripe_webhook` | Approve Stripe once if needed; the Worker receives links and the signing secret only. Follow the single next step returned. Live only when requested. |
112
103
  | "Is tax set up?" | `stripe_api_read` `GET /v1/tax/settings`, and `GET /v1/tax/registrations` | Seller "stripe": Stripe Tax needs `status: active` (the business address in Settings, Tax), or checkouts fail; with no registrations it collects no tax anywhere: say so, the accountant decides where to register. Seller "stripe-managed": Stripe files the tax; the connect page's test checkout said whether Managed Payments is on. Change nothing yourself. |
113
104
  | "Is the shop working?" | `npx --no-install homie-studio shop` | Open (test or live) or exactly what is missing, the last 30 days, the webhook address. |
114
105
  | "How are sales?" / "Show me the sales" | `shop` and `shop orders` first (the studio's own books); with Stripe's MCP, read-only: `stripe_analytics`, or `stripe_api_read` on `/v1/balance`, `/v1/payouts`, `/v1/checkout/sessions` | Counts, money and payouts in a few lines; never a buyer's name, email or card. Never a write to answer a question. The owner's `/_studio/office/shop` links every Stripe page and gives the accountant a CSV. |
@@ -158,7 +149,7 @@ Read SHOP.md for a round to table 7, `shop.book` and $20 monthly giving.
158
149
  Menus let guests choose several quantities on Stripe; signed invoices provide actual
159
150
  paid lines and the app reference. Slot Payment Links enforce completed-session
160
151
  capacity. Pending guests do not block others. The owner's AI syncs published slots
161
- with `stripe_bookings_sync`; the app shows Full from verified availability.
152
+ with `stripe_shop_plan` and the complete slot manifest; the app shows Full from verified availability.
162
153
  Recurring Prices provide monthly preset gifts and memberships. Connect enables the
163
154
  no-code customer portal login through Stripe's API, without a Dashboard toggle.
164
155
  Paid invoice periods govern membership; failed renewal never extends it.
@@ -1,11 +1,14 @@
1
1
  ---
2
2
  name: sound
3
3
  description: Make a game's sound effects and synthesized music on the creator's own computer, free, with no provider or account — a set of effects (jump, coin, hit, explosion, win, lose, UI) rendered from presets, a short synthesized theme or score from chords and patterns with stems and seamless section loops, a mix of any audio files and expressions — measured for loudness, clipping, late starts and what a phone speaker loses, then wired into the game with a small player that starts on the first touch and switches music on bar lines. Use when someone asks for sound effects, SFX, game audio, a chiptune or synth theme, background music without a paid service, music that changes with the game, or says a game is silent.
4
+ compatibility: Node 22 and ffmpeg for local synthesis; official ElevenLabs MCP for requested generated sound.
5
+ metadata:
6
+ providers: elevenlabs
4
7
  ---
5
8
 
6
9
  # Sound for a studio's game
7
10
 
8
- Everything here runs on this computer with Node 22 and ffmpeg: nothing is sent anywhere and nothing
11
+ Local synthesis runs on this computer with Node 22 and ffmpeg: nothing is sent anywhere and nothing
9
12
  costs money. Sung songs and produced music from a model are the `music` skill (ElevenLabs, paid);
10
13
  this skill is effects and synthesized music. They share the studio's `music/` folder and manifest, so
11
14
  a synthesized theme gets a song page just like a rendered one.
@@ -154,3 +157,69 @@ numbers, the warnings you fixed and any you did not, and that it cost nothing.
154
157
  - Never use a mp3 for an effect or a loop: encoders pad the start with silence (a late hit) and the
155
158
  ends (a gap at the loop). WAV or Ogg.
156
159
  - Never imitate a real song, artist or brand jingle.
160
+
161
+ ## Generated sound on request
162
+
163
+ Use the official ElevenLabs MCP for requested generated effects. Discover its current
164
+ sound capabilities and schema through the official plugin; never assume a tool exists.
165
+ If the hosted connection lacks sound generation, say so; keep local synthesis available
166
+ as Homie-owned, free functionality. Specify duration, loopability, envelope and material
167
+ from the scene. Audition on phone speakers, remove clicks, and trigger it only on the
168
+ intended game event after the browser audio unlock gesture.
169
+
170
+ ## Official generation workflow
171
+
172
+ The owner's AI uses the provider's official plugin/MCP in its own client. Homie never
173
+ asks for, stores, reads or prints provider credentials. Read the current provider
174
+ skill and discover its available tools and input schemas; host prefixes vary.
175
+ Connection instructions and verified sources: [official integrations](../studio-setup/references/OFFICIAL-MEDIA.md).
176
+ If tools are missing, connect that official plugin once in the client's browser flow.
177
+ Do not fall back to a Homie client, key file, environment key or CLI generation wrapper.
178
+
179
+ Before spending, state the model, count/duration, estimated cost and remaining approved
180
+ budget. Use the owner's existing authorization; ask only if the spend is outside it.
181
+ Record the request ID as soon as accepted. Poll that same request; never resubmit to
182
+ retry a slow job. Unknown cost is unknown, never zero. Provider billing is authoritative;
183
+ Homie's local receipt cannot enforce spending in another client.
184
+
185
+ ## Place and prove the result
186
+
187
+ Download the completed tool's output using the AI client's file/download facilities.
188
+ Keep temporary signed URLs out of committed records. Keep the original in the studio's
189
+ art/job folder; put the optimized delivered asset beside its game/app, or in music/ or
190
+ videos/ for a published work. Record a receipt with these fields:
191
+
192
+ ```json
193
+ {
194
+ "provider": "elevenlabs",
195
+ "tool": "actual discovered tool",
196
+ "model": "exact endpoint returned by the provider",
197
+ "requestId": "actual request ID",
198
+ "prompt": "the actual prompt, including style constraints",
199
+ "styleRevision": "revision from the studio's style",
200
+ "rights": {"source": "provider/model terms URL", "checkedAt": "YYYY-MM-DD", "commercial": false, "note": "record the actual plan and permitted use"},
201
+ "cost": {"status": "unknown"}
202
+ }
203
+ ```
204
+
205
+ For ElevenLabs use its actual discovered tool and model, and its flow/session or request
206
+ identifier. A measured cost uses `{"status":"actual","amount":1,"unit":"credits"}`;
207
+ a quote uses `estimate`. Never claim commercial rights from the fact that generation
208
+ succeeded. Read the provider and model terms for the account's plan; record attribution,
209
+ reference-image permissions and any restrictions. Mark test fixtures `"fixture": true`.
210
+
211
+ Wire the delivered path into the scene or manifest, then run:
212
+
213
+ ```sh
214
+ npx --no-install homie-studio media import --file <downloaded-file> --receipt <receipt.json> --out games/<id>/assets/<name> --used-by games/<id>/view.ts
215
+ ```
216
+
217
+ This copies bytes, verifies a static reference, hashes the asset and records allowlisted
218
+ provenance in `media/provenance.json`. Reimporting the same output is idempotent; it
219
+ never generates or charges. Include each generation step's receipt for derived assets.
220
+ Run the scene and look/listen: a file and static reference are not proof that it renders
221
+ or plays. Report the placed path, usage evidence, rights and actual/estimated cost.
222
+
223
+ The toolkit’s `lib/media-workflows.mjs` declares this skill’s verified tool references.
224
+ Validate them against the host’s current tool inventory before execution; tool discovery
225
+ is required for generation capabilities that have no stable documented name.