@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,520 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sound.mjs: sound effects and synthesized music for a Homie studio, made on this computer.
4
+ * No provider, no account, no money: every file is rendered from a JSON spec by plain JavaScript,
5
+ * then measured (and encoded) with ffmpeg.
6
+ *
7
+ * check node, ffmpeg and its encoders, the studio
8
+ * presets the effect presets, the instruments and the grooves
9
+ * sfx <set> [--kit arcade|platformer|shooter|party|ui] [--only a,b] [--spec file]
10
+ * [--style clean|retro|soft] [--variants n] [--for-game <id>]
11
+ * a set of effects: music/<set>/sfx/<name>-<v>.wav, sfx.json, a reel to listen to
12
+ * score <slug> --spec <score.json> [--lufs -14] [--loops a,b|all] [--loop-stems]
13
+ * a synthesized score: master, page mp3, stems, seamless section loops
14
+ * mix <slug> --spec <mix.json> parts (files, ffmpeg aevalsrc expressions, effects) placed, panned, faded, summed
15
+ * analyze <file> [--kind sfx|music|capture] [--sheet out.png]
16
+ * loudness (also through a phone speaker), peaks, clipping, DC, late start,
17
+ * gaps, bands, stereo, onsets, hum: numbers and plain-words warnings
18
+ * wire <slug...> --game <id> copy sets and loops into the game with sound.js (a player) and sound.json
19
+ * add <slug> --title "<Title>" [--blurb] [--for-game <id>] [--publish]
20
+ * the manifest entry, so the studio's site gets a page for it
21
+ * publish <slug> rebuild and redeploy the site (the music skill's publish)
22
+ *
23
+ * Every command takes --json. Run it from inside the studio (the folder with studio.json).
24
+ */
25
+ import { spawnSync } from 'node:child_process';
26
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
27
+ import { basename, dirname, join, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { need, probe, run } from '../../music/scripts/lib/audio.mjs';
30
+ import { experienceDir, experienceJson, SLUG, findStudio, readJson, rel, upsertEntry, writeJson } from '../../music/scripts/lib/studio.mjs';
31
+ import { measure, r128, sheetPng, warnings } from './lib/measure.mjs';
32
+ import { INSTRUMENTS, loop as loopSection, master as masterMix, normalise, render as renderScore, seamWarning } from './lib/score.mjs';
33
+ import { KITS, LEVELS, PRESETS, renderEffect } from './lib/sfx.mjs';
34
+ import { RATE, addInto, dbToGain, dcBlock, levels as levelsOf, limit, peakOf, scale, stereo, wavBytes } from './lib/synth.mjs';
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const argv = process.argv.slice(2);
38
+ const flags = new Map();
39
+ const pos = [];
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a.startsWith('--')) { const k = a.slice(2); const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) flags.set(k, true); else { flags.set(k, v); i++; } } else pos.push(a);
43
+ }
44
+ const JSON_OUT = flags.has('json');
45
+ const print = (o) => {
46
+ if (JSON_OUT) { process.stdout.write(`${JSON.stringify(o, null, 2)}\n`); return; }
47
+ if (o.ok === false) { process.stdout.write(`sound: ${o.why}\n`); return; }
48
+ const lines = [];
49
+ for (const [k, v] of Object.entries(o)) if (k !== 'ok' && k !== 'command') lines.push(`${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
50
+ process.stdout.write(`${lines.join('\n')}\n`);
51
+ };
52
+
53
+ function studioRoot() {
54
+ const root = findStudio();
55
+ if (!root) throw new Error('not inside a Homie studio (no studio.json here or above). The studio-setup skill makes one.');
56
+ return root;
57
+ }
58
+ function jobDir(root, slug) {
59
+ if (!SLUG.test(String(slug ?? ''))) throw new Error('give a slug: lowercase letters, digits and hyphens (up to 40), e.g. "theme" or "gem-rush-sfx"');
60
+ const d = join(root, 'music', slug);
61
+ mkdirSync(d, { recursive: true });
62
+ return d;
63
+ }
64
+ function freeGb(dir) { try { const f = statfsSync(dir); return (f.bavail * f.bsize) / 1e9; } catch { return 99; } }
65
+ function guardDisk(dir, needGb = 2) { if (freeGb(dir) < needGb + 8) throw new Error(`only ${freeGb(dir).toFixed(1)} GB free on this disk; free some space first (renders stop below 10 GB)`); }
66
+ const writeWav = (file, buf, opts) => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, wavBytes(buf, opts)); return file; };
67
+
68
+ function encode(src, out, kind) {
69
+ const args = ['-hide_banner', '-loglevel', 'error', '-y', '-i', src];
70
+ if (kind === 'mp3') args.push('-c:a', 'libmp3lame', '-b:a', '192k', '-ar', '48000', out);
71
+ else if (kind === 'ogg') {
72
+ let r = run('ffmpeg', [...args, '-c:a', 'libvorbis', '-q:a', '6', out]);
73
+ if (r.code === 0) return { file: out, codec: 'vorbis' };
74
+ r = run('ffmpeg', [...args, '-c:a', 'libopus', '-b:a', '160k', out]);
75
+ if (r.code !== 0) throw new Error(`ffmpeg could not encode ${out}: ${r.stderr.trim().split('\n').pop()}`);
76
+ return { file: out, codec: 'opus' };
77
+ }
78
+ const r = run('ffmpeg', args);
79
+ if (r.code !== 0) throw new Error(`ffmpeg could not encode ${out}: ${r.stderr.trim().split('\n').pop()}`);
80
+ return { file: out, codec: kind };
81
+ }
82
+
83
+ /** Bring a stereo buffer to an integrated loudness (measured by ffmpeg's R128), peaks held under the ceiling. */
84
+ function toLoudness(buf, target, ceilingDb, work) {
85
+ const tmp = join(work, `loud-${process.pid}.wav`);
86
+ let lufs = null;
87
+ for (let pass = 0; pass < 3; pass++) {
88
+ writeWav(tmp, buf, { bits: 24 });
89
+ lufs = r128(tmp).lufs;
90
+ if (lufs === null || !Number.isFinite(lufs)) break;
91
+ const d = target - lufs;
92
+ if (Math.abs(d) < 0.4) break;
93
+ scale(buf, dbToGain(d));
94
+ limit(buf, { ceiling: dbToGain(ceilingDb), releaseMs: 150, lookaheadMs: 5 });
95
+ }
96
+ rmSync(tmp, { force: true });
97
+ return buf;
98
+ }
99
+
100
+ /* ---------------------------------------------------------------- check / presets */
101
+
102
+ function check() {
103
+ const ffmpeg = need('ffmpeg') && need('ffprobe');
104
+ const enc = ffmpeg ? run('ffmpeg', ['-hide_banner', '-encoders']).stdout.toString() : '';
105
+ const root = findStudio();
106
+ const node = Number(process.versions.node.split('.')[0]);
107
+ return {
108
+ ok: ffmpeg && node >= 22, command: 'check', node: process.versions.node, ffmpeg,
109
+ encoders: { mp3: /libmp3lame/.test(enc), vorbis: /libvorbis/.test(enc), opus: /libopus/.test(enc) },
110
+ studio: root ? { root, games: existsSync(join(root, 'games')) ? readdirSync(join(root, 'games')).filter((d) => existsSync(join(root, 'games', d, 'game.json'))) : [] } : null,
111
+ provider: 'none: everything is synthesized here, nothing is sent anywhere, nothing costs money',
112
+ ...(ffmpeg ? {} : { why: 'ffmpeg is missing: brew install ffmpeg (macOS) or the system package; the person approves the install' }),
113
+ };
114
+ }
115
+
116
+ function presets() {
117
+ return {
118
+ ok: true, command: 'presets',
119
+ effects: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, `${v.about} (${v.level})`])),
120
+ kits: KITS, levels: LEVELS,
121
+ instruments: Object.fromEntries(Object.entries(INSTRUMENTS).map(([k, v]) => [k, `${v.about} (${v.role})`])),
122
+ grooves: ['four', 'backbeat', 'half', 'break', 'shuffle', 'hats', 'pulse', 'none'],
123
+ patterns: ['root', 'root8', 'pulse', 'octaves', 'chords', 'stabs', 'arp', 'arp-down', 'arp-updown', 'or notes: "A4 . C5 - | E5*2 D5 C5 ."'],
124
+ };
125
+ }
126
+
127
+ /* ---------------------------------------------------------------- sfx */
128
+
129
+ function sfxSpec() {
130
+ if (flags.has('spec')) {
131
+ const s = readJson(resolve(String(flags.get('spec'))), null);
132
+ if (!s) throw new Error(`could not read ${flags.get('spec')} as JSON`);
133
+ return s;
134
+ }
135
+ const kit = String(flags.get('kit') ?? 'arcade');
136
+ const names = flags.has('only') ? String(flags.get('only')).split(',').map((x) => x.trim()).filter(Boolean) : KITS[kit];
137
+ if (!names) throw new Error(`no kit "${kit}" (${Object.keys(KITS).join(', ')})`);
138
+ return { style: flags.get('style') ?? 'clean', variants: Number(flags.get('variants') ?? 3), effects: names.map((n) => ({ name: n })) };
139
+ }
140
+
141
+ const ONE_TAKE = new Set(['win', 'lose', 'go', 'countdown', 'alarm', 'teleport', 'powerup', 'confirm', 'back', 'deny']);
142
+
143
+ function sfx(root) {
144
+ const set = pos[1];
145
+ const dir = jobDir(root, set);
146
+ guardDisk(dir);
147
+ const spec = sfxSpec();
148
+ const style = flags.get('style') ?? spec.style ?? 'clean';
149
+ const variants = Math.max(1, Math.min(8, Number(flags.get('variants') ?? spec.variants ?? 3)));
150
+ const out = join(dir, 'sfx');
151
+ rmSync(out, { recursive: true, force: true });
152
+ mkdirSync(out, { recursive: true });
153
+ const made = [];
154
+ const reel = [];
155
+ for (const [i, e] of (spec.effects ?? []).entries()) {
156
+ if (!e?.name || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(e.name)) throw new Error(`effect ${i + 1}: a name of lowercase letters, digits and hyphens`);
157
+ const files = [];
158
+ // Variants keep a sound heard over and over (a jump, a hit) from machine-gunning; a fanfare or a signal
159
+ // heard once a round wants to be the same every time: one take.
160
+ const takes = Math.max(1, Math.min(8, Number(e.variants ?? (ONE_TAKE.has(e.preset ?? e.name) ? 1 : variants))));
161
+ for (let v = 1; v <= takes; v++) {
162
+ // A variant: the same recipe a touch higher or lower and a little longer or shorter, with its own noise.
163
+ const jitter = takes === 1 ? 0 : ((v - 1) / (takes - 1) - 0.5) * 1.2;
164
+ const spec1 = { ...e, style: e.style ?? style, seed: (e.seed ?? 1) + v * 101, pitch: (e.pitch ?? 0) + jitter, length: (e.length ?? 1) * (1 + jitter * 0.05) };
165
+ const { buf, facts } = renderEffect(spec1);
166
+ const file = join(out, `${e.name}-${v}.wav`);
167
+ writeWav(file, buf, { bits: 16, channels: e.stereo ? 2 : 1 });
168
+ // An Ogg (Opus) copy a tenth the size; the WAV stays for browsers that cannot decode Ogg.
169
+ const ogg = encode(file, file.replace(/\.wav$/, '.ogg'), 'ogg');
170
+ files.push({ file: rel(root, file), ogg: rel(root, ogg.file), ...facts });
171
+ if (v === 1) reel.push(buf);
172
+ }
173
+ made.push({ name: e.name, preset: e.preset ?? e.name, about: PRESETS[e.preset ?? e.name].about, files });
174
+ }
175
+ // A reel to listen to: every effect once, half a second apart.
176
+ const gap = 0.5;
177
+ const reelBuf = stereo(reel.reduce((a, b) => a + b.L.length / RATE + gap, 0.2));
178
+ let at = 0.1;
179
+ for (const b of reel) { addInto(reelBuf, b, { at }); at += b.L.length / RATE + gap; }
180
+ const reelWav = writeWav(join(dir, 'work', `${set}-reel.wav`), reelBuf, { bits: 16 });
181
+ encode(reelWav, join(dir, `${set}.mp3`), 'mp3');
182
+ let sheet = null;
183
+ try { sheet = rel(root, sheetPng(reelWav, join(dir, `${set}-reel.png`))); } catch { /* no picture, still fine */ }
184
+ const checks = made.flatMap((m) => m.files.map((f) => ({ name: m.name, ...f }))).map((f) => {
185
+ const m = measure(join(root, f.file), { silenceDb: -60 });
186
+ return { file: f.file, warnings: warnings(m, 'sfx'), startMs: m.startMs, peakDb: m.samplePeakDb, under300: m.under300Share };
187
+ });
188
+ const info = { v: 1, kind: 'sfx', set, style, variants, at: new Date().toISOString(), made: 'synthesized on this computer by the Homie plugin\'s sound skill (no samples, no provider)', ...(flags.has('for-game') ? { for: { game: String(flags.get('for-game')) } } : {}), effects: made, spec: { ...spec, style, variants } };
189
+ writeJson(join(dir, 'sfx.json'), info);
190
+ const warn = checks.filter((c) => c.warnings.length);
191
+ return {
192
+ ok: true, command: 'sfx', set, style, variants, effects: made.map((m) => `${m.name}: ${m.files.length} file(s), ${m.files[0].seconds}s, loudest ${m.files[0].loudestDb} dB (${m.files[0].level})`),
193
+ reel: rel(root, join(dir, `${set}.mp3`)), sheet, spec: rel(root, join(dir, 'sfx.json')),
194
+ warnings: warn.length ? warn.map((c) => `${c.file}: ${c.warnings.join('; ')}`) : 'none: every file starts at once, no clipping, no DC',
195
+ next: `look at ${sheet ?? 'the reel'}; then: node sound.mjs wire ${set} --game <id>`,
196
+ };
197
+ }
198
+
199
+ /* ---------------------------------------------------------------- score */
200
+
201
+ function score(root) {
202
+ const slug = pos[1];
203
+ const dir = jobDir(root, slug);
204
+ guardDisk(dir);
205
+ const specPath = resolve(String(flags.get('spec') ?? join(dir, 'score.json')));
206
+ const raw = readJson(specPath, null);
207
+ if (!raw) throw new Error(`no score at ${specPath} (write one: references/SCORE.md)`);
208
+ const s = normalise(raw);
209
+ if (specPath !== join(dir, 'score.json')) writeJson(join(dir, 'score.json'), raw);
210
+ const work = join(dir, 'work'); mkdirSync(work, { recursive: true });
211
+ const lufs = Number(flags.get('lufs') ?? s.master?.lufs ?? -14);
212
+ const t0 = Date.now();
213
+ const r = renderScore(s);
214
+ // Master: peaks under the ceiling, then to the loudness target, measured. Stems get the same gain.
215
+ const before = peakOf(r.mix);
216
+ const mix = masterMix(r.mix, { ceilingDb: -1.5 });
217
+ toLoudness(mix, lufs, -1.2, work);
218
+ const gain = peakOf(mix) / Math.max(1e-9, before);
219
+ const masterWav = writeWav(join(dir, `${slug}-master.wav`), mix, { bits: 24 });
220
+ encode(masterWav, join(dir, `${slug}.mp3`), 'mp3');
221
+ const stemsDir = join(dir, 'stems'); rmSync(stemsDir, { recursive: true, force: true }); mkdirSync(stemsDir, { recursive: true });
222
+ const stems = [];
223
+ for (const [name, st] of Object.entries(r.stems)) {
224
+ if (!(peakOf(st) > 1e-5)) continue;
225
+ scale(st, gain);
226
+ writeWav(join(stemsDir, `${name}.wav`), st, { bits: 16 });
227
+ stems.push(name);
228
+ }
229
+ // Seamless loops of the sections asked for (default: the ones the score marks, else the longest).
230
+ const want = flags.has('loops') ? String(flags.get('loops')) : (s.loops ?? []).join(',');
231
+ const names = want === 'all' ? s.sections.map((x) => x.name) : want ? want.split(',').map((x) => x.trim()).filter(Boolean) : [s.sections.reduce((a, b) => (b.bars > a.bars ? b : a)).name];
232
+ const loops = [];
233
+ for (const name of names) {
234
+ // The master's gain and limiter go on inside loopSection, across the three copies it cuts the loop from:
235
+ // limiting the cut loop by itself puts a step of gain at the wrap.
236
+ const lp = loopSection(s, name, { gain, ceilingDb: -1.2 });
237
+ const base = `${slug}-${name}-loop-${lp.bars}bars`;
238
+ const wav = writeWav(join(dir, `${base}.wav`), lp.mix, { bits: 16 });
239
+ const ogg = encode(wav, join(dir, `${base}.ogg`), 'ogg');
240
+ const seam = lp.seam;
241
+ writeJson(join(dir, `${base}.json`), { section: name, bars: lp.bars, seconds: +lp.seconds.toFixed(6), bpm: s.bpm, beatsPerBar: s.beatsPerBar, seam, codec: ogg.codec });
242
+ if (flags.has('loop-stems')) {
243
+ for (const [st, b] of Object.entries(lp.stems)) { if (!(peakOf(b) > 1e-5)) continue; scale(b, gain); writeWav(join(dir, 'loops', name, `${st}.wav`), b, { bits: 16 }); }
244
+ }
245
+ loops.push({ section: name, bars: lp.bars, seconds: +lp.seconds.toFixed(3), seam, wav: rel(root, wav), ogg: rel(root, ogg.file) });
246
+ }
247
+ const m = measure(join(dir, `${slug}.mp3`));
248
+ // A loop over the seam limit is a warning like any other: an empty list must mean the loops were checked and pass.
249
+ const seamWarnings = loops.map((l) => seamWarning(l.section, l.seam)).filter(Boolean);
250
+ const allWarnings = [...warnings(m, 'music'), ...seamWarnings];
251
+ // plan.json: the bar grid, in the shape the video skill reads to cut a trailer on this music's bar lines.
252
+ const plan = {
253
+ title: s.title ?? slug, bpm: s.bpm, key: s.key ?? null, beatsPerBar: s.beatsPerBar, vocals: false, synthesized: true,
254
+ sections: r.grid.sections.map((x) => ({ name: x.name, bars: x.bars, startMs: Math.round(x.at * 1000), durationMs: Math.round(x.bars * r.grid.barSeconds * 1000) })),
255
+ totalMs: Math.round(r.grid.seconds * 1000),
256
+ };
257
+ writeJson(join(dir, 'plan.json'), plan);
258
+ writeJson(join(dir, 'master.json'), { seconds: m.seconds, mp3: { lufs: m.loudness.lufs, truePeak: m.loudness.truePeakDb }, phoneLufs: m.loudness.phoneLufs });
259
+ writeJson(join(dir, 'render.json'), { at: new Date().toISOString(), made: 'synthesized', seconds: m.seconds, grid: r.grid, loudness: m.loudness, stems, loops, warnings: allWarnings, renderMs: Date.now() - t0 });
260
+ let sheet = null;
261
+ try { sheet = rel(root, sheetPng(join(dir, `${slug}.mp3`), join(dir, `${slug}.png`))); } catch { /* */ }
262
+ return {
263
+ ok: true, command: 'score', slug, title: plan.title, seconds: m.seconds, bars: r.grid.bars, bpm: s.bpm,
264
+ loudness: `${m.loudness.lufs} LUFS, true peak ${m.loudness.truePeakDb} dBTP, through a phone speaker ${m.loudness.phoneLufs} LUFS`,
265
+ page: rel(root, join(dir, `${slug}.mp3`)), master: rel(root, masterWav), stems: stems.map((x) => `stems/${x}.wav`),
266
+ loops: loops.map((l) => `${l.section}: ${l.bars} bars, ${l.seconds}s, seam ${l.seam} ${l.seam > 1 ? 'OVER 1: NOT SEAMLESS, see warnings' : '(1 or less is seamless)'} ${l.wav}`),
267
+ loopsSeamless: seamWarnings.length === 0,
268
+ sheet, warnings: allWarnings, renderMs: Date.now() - t0,
269
+ };
270
+ }
271
+
272
+ /* ---------------------------------------------------------------- mix */
273
+
274
+ /** One part of a mix as a stereo buffer: a file, an ffmpeg aevalsrc expression, or an effect preset. */
275
+ function partBuffer(root, p, i) {
276
+ const where = `part ${i + 1}`;
277
+ if (p.effect) return renderEffect(typeof p.effect === 'string' ? { name: p.effect } : p.effect).buf;
278
+ let input;
279
+ if (p.file) {
280
+ const f = resolve(root, String(p.file));
281
+ if (!existsSync(f)) throw new Error(`${where}: no file ${p.file}`);
282
+ input = ['-i', f];
283
+ } else if (p.expr) {
284
+ const exprs = Array.isArray(p.expr) ? p.expr : [p.expr];
285
+ if (!(exprs.length === 1 || exprs.length === 2)) throw new Error(`${where}: expr is one expression (mono) or two (left, right)`);
286
+ for (const e of exprs) {
287
+ if (typeof e !== 'string' || !e.trim()) throw new Error(`${where}: an empty expression`);
288
+ if (/['\\;[\]]/.test(e)) throw new Error(`${where}: an expression may not hold quotes, backslashes, semicolons or brackets (ffmpeg's filter parser takes them)`);
289
+ if (e.length > 8000) throw new Error(`${where}: an expression over 8000 characters; split it into two parts (they sum the same)`);
290
+ if (/[<>]=?/.test(e)) throw new Error(`${where}: aevalsrc has no comparison operators: use gte(a,b), lte(a,b), gt(a,b), lt(a,b)`);
291
+ }
292
+ if (!(Number(p.seconds) > 0 && Number(p.seconds) <= 600)) throw new Error(`${where}: an expression needs "seconds" (0..600)`);
293
+ input = ['-f', 'lavfi', '-i', `aevalsrc=exprs=${exprs.map((e) => `'${e}'`).join('|')}:s=${RATE}:d=${Number(p.seconds)}${exprs.length === 2 ? ':c=stereo' : ''}`];
294
+ } else throw new Error(`${where}: a part is { "file" } or { "expr", "seconds" } or { "effect" }`);
295
+ const chain = [];
296
+ if (p.from || p.seconds) chain.push(`atrim=${p.from ? `start=${Number(p.from)}` : 'start=0'}${p.file && p.seconds ? `:duration=${Number(p.seconds)}` : ''}`, 'asetpts=PTS-STARTPTS');
297
+ if (p.filter) {
298
+ if (/['\\;]/.test(String(p.filter))) throw new Error(`${where}: a filter may not hold quotes, backslashes or semicolons`);
299
+ chain.push(String(p.filter));
300
+ }
301
+ const args = ['-hide_banner', '-loglevel', 'error', ...input, '-vn', '-ac', '2', '-ar', String(RATE)];
302
+ if (chain.length) args.push('-af', chain.join(','));
303
+ args.push('-f', 'f32le', '-');
304
+ const r = run('ffmpeg', args);
305
+ if (r.code !== 0) throw new Error(`${where}: ffmpeg said: ${r.stderr.trim().split('\n').pop()}`);
306
+ const b = r.stdout;
307
+ const all = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength - (b.byteLength % 8)));
308
+ const n = all.length / 2;
309
+ const out = stereo(n / RATE);
310
+ for (let k = 0; k < n; k++) { out.L[k] = all[2 * k]; out.R[k] = all[2 * k + 1]; }
311
+ // An expression that exits 0 can still be NaN or silence (0*inf, a random() slot): say so, never mix it quietly.
312
+ let bad = 0; for (let k = 0; k < n; k += 11) if (!Number.isFinite(out.L[k]) || !Number.isFinite(out.R[k])) bad++;
313
+ if (bad) throw new Error(`${where}: the expression produced NaN samples (0*inf is NaN; write exp(-max(0,t-a)*k))`);
314
+ if (!(peakOf(out) > 1e-6)) throw new Error(`${where}: rendered silence (exit code 0 is not audio: check the expression's envelope and slots)`);
315
+ return out;
316
+ }
317
+
318
+ function mix(root) {
319
+ const slug = pos[1];
320
+ const dir = jobDir(root, slug);
321
+ guardDisk(dir);
322
+ const spec = readJson(resolve(String(flags.get('spec') ?? join(dir, 'mix.json'))), null);
323
+ if (!spec?.parts?.length) throw new Error('a mix spec is { "parts": [ { "file" | "expr"+"seconds" | "effect", "at", "db", "pan", "fadeIn", "fadeOut", "repeat", "every", "filter" } ], "master": { "lufs" } }');
324
+ const placed = [];
325
+ for (const [i, p] of spec.parts.entries()) {
326
+ const buf = partBuffer(root, p, i);
327
+ const repeat = Math.max(1, Math.min(512, Number(p.repeat ?? 1)));
328
+ const every = Number(p.every ?? buf.L.length / RATE);
329
+ const fi = Math.round(Number(p.fadeIn ?? 0) * RATE); const fo = Math.round(Number(p.fadeOut ?? 0) * RATE);
330
+ for (let k = 0; k < fi && k < buf.L.length; k++) { const g = k / fi; buf.L[k] *= g; buf.R[k] *= g; }
331
+ for (let k = 0; k < fo && k < buf.L.length; k++) { const g = k / fo; buf.L[buf.L.length - 1 - k] *= g; buf.R[buf.R.length - 1 - k] *= g; }
332
+ for (let k = 0; k < repeat; k++) placed.push({ buf, at: Number(p.at ?? 0) + k * every, gain: dbToGain(Number(p.db ?? 0)), pan: Number(p.pan ?? 0), i });
333
+ }
334
+ const length = Math.max(...placed.map((p) => p.at + p.buf.L.length / RATE)) + Number(spec.tail ?? 0);
335
+ const out = stereo(length);
336
+ // Summed at the levels written: no automatic normalisation (ffmpeg's amix divides by the number of inputs by default).
337
+ for (const p of placed) {
338
+ if (p.pan) {
339
+ const g = p.pan; const l = Math.cos((g + 1) * Math.PI / 4) * Math.SQRT2; const r = Math.sin((g + 1) * Math.PI / 4) * Math.SQRT2;
340
+ const off = Math.round(p.at * RATE);
341
+ for (let k = 0; k < p.buf.L.length && off + k < out.L.length; k++) { out.L[off + k] += p.buf.L[k] * p.gain * l; out.R[off + k] += p.buf.R[k] * p.gain * r; }
342
+ } else addInto(out, p.buf, { at: p.at, gain: p.gain });
343
+ }
344
+ dcBlock(out);
345
+ const work = join(dir, 'work'); mkdirSync(work, { recursive: true });
346
+ const peakBefore = levelsOf(out).peakDb;
347
+ limit(out, { ceiling: dbToGain(spec.master?.ceilingDb ?? -1.5), releaseMs: 120 });
348
+ if (spec.master?.lufs !== undefined) toLoudness(out, Number(spec.master.lufs), spec.master?.ceilingDb ?? -1.2, work);
349
+ const wav = writeWav(join(dir, `${slug}-master.wav`), out, { bits: 24 });
350
+ encode(wav, join(dir, `${slug}.mp3`), 'mp3');
351
+ const m = measure(wav);
352
+ writeJson(join(dir, 'master.json'), { seconds: m.seconds, mp3: { lufs: m.loudness.lufs, truePeak: m.loudness.truePeakDb } });
353
+ return { ok: true, command: 'mix', slug, parts: spec.parts.length, placed: placed.length, seconds: m.seconds, peakBeforeLimiterDb: peakBefore, loudness: m.loudness, master: rel(root, wav), page: rel(root, join(dir, `${slug}.mp3`)), warnings: warnings(m, 'music') };
354
+ }
355
+
356
+ /* ---------------------------------------------------------------- analyze */
357
+
358
+ function analyze() {
359
+ const file = resolve(String(pos[1] ?? ''));
360
+ if (!existsSync(file)) throw new Error('usage: analyze <audio file> [--kind sfx|music|capture] [--sheet out.png]');
361
+ const kind = String(flags.get('kind') ?? (probe(file).duration < 3 ? 'sfx' : 'music'));
362
+ const m = measure(file);
363
+ let sheet = null;
364
+ if (flags.has('sheet')) sheet = sheetPng(file, resolve(String(flags.get('sheet') === true ? file.replace(/\.[a-z0-9]+$/i, '.png') : flags.get('sheet'))));
365
+ return { ok: true, command: 'analyze', kind, ...m, warnings: warnings(m, kind), sheet };
366
+ }
367
+
368
+ /* ---------------------------------------------------------------- wire */
369
+
370
+ function gameMode(gameDir) {
371
+ const g = readJson(experienceJson(gameDir), {});
372
+ return g.build?.mode === 'static' ? 'static' : 'bundle';
373
+ }
374
+
375
+ function wire(root) {
376
+ const game = String(flags.get('game') ?? '');
377
+ const gameDir = experienceDir(root, game);
378
+ if (!game || !existsSync(experienceJson(gameDir))) throw new Error('--game <id>: a game in this studio (games/<id>/game.json)');
379
+ const slugs = pos.slice(1);
380
+ if (!slugs.length) throw new Error('usage: wire <sfx set or score slug...> --game <id>');
381
+ const mode = gameMode(gameDir);
382
+ const assets = mode === 'static' ? join(gameDir, 'sound') : join(gameDir, 'public', 'sound');
383
+ mkdirSync(assets, { recursive: true });
384
+ const manifest = readJson(join(assets, 'sound.json'), { v: 1, sfx: {}, music: {} });
385
+ let bytes = 0;
386
+ const copied = [];
387
+ const seams = [];
388
+ for (const slug of slugs) {
389
+ const dir = join(root, 'music', slug);
390
+ const sfxInfo = readJson(join(dir, 'sfx.json'), null);
391
+ if (sfxInfo) {
392
+ for (const e of sfxInfo.effects) {
393
+ // Each variant as [ogg, wav]: sound.js plays the first this browser decodes.
394
+ manifest.sfx[e.name] = e.files.map((f) => [f.ogg, f.file].filter((x) => x && existsSync(join(root, x))).map((x) => { const b = basename(x); copyFileSync(join(root, x), join(assets, b)); bytes += statSync(join(assets, b)).size; copied.push(b); return b; }));
395
+ }
396
+ continue;
397
+ }
398
+ const plan = readJson(join(dir, 'plan.json'), null);
399
+ const loopsFound = existsSync(dir) ? readdirSync(dir).filter((f) => /-loop-\d+bars\.wav$/.test(f)) : [];
400
+ if (plan && loopsFound.length) {
401
+ const loops = {};
402
+ for (const f of loopsFound) {
403
+ const info = readJson(join(dir, f.replace(/\.wav$/, '.json')), {});
404
+ const name = info.section ?? 'main';
405
+ const sw = seamWarning(name, info.seam);
406
+ if (sw) seams.push(`music/${slug}: ${sw}`);
407
+ // Ogg first (a tenth of the bytes), the WAV for a browser that cannot decode Ogg: sound.js takes the first that decodes.
408
+ const list = [];
409
+ for (const g of [f.replace(/\.wav$/, '.ogg'), f]) {
410
+ if (!existsSync(join(dir, g))) continue;
411
+ copyFileSync(join(dir, g), join(assets, g)); bytes += statSync(join(assets, g)).size; copied.push(g); list.push(g);
412
+ }
413
+ loops[name] = list;
414
+ }
415
+ // Order: the plan's section order, so music('<slug>') starts with the first section.
416
+ const ordered = Object.fromEntries(plan.sections.map((x) => x.name).filter((n, i, a) => loops[n] && a.indexOf(n) === i).map((n) => [n, loops[n]]));
417
+ for (const [k, v] of Object.entries(loops)) if (!ordered[k]) ordered[k] = v;
418
+ manifest.music[slug] = { bpm: plan.bpm, beatsPerBar: plan.beatsPerBar ?? 4, barSeconds: (60 / plan.bpm) * (plan.beatsPerBar ?? 4), loops: ordered };
419
+ continue;
420
+ }
421
+ throw new Error(`music/${slug}: neither an effect set (sfx.json) nor a score with loops (make loops with: score ${slug} --loops <sections>, or music.mjs loop)`);
422
+ }
423
+ writeJson(join(assets, 'sound.json'), manifest);
424
+ const sizeOf = (f) => (existsSync(join(assets, f)) ? statSync(join(assets, f)).size : 0);
425
+ const downloadBytes = Object.values(manifest.sfx).flat().reduce((a, f) => a + sizeOf(Array.isArray(f) ? f[0] : f), 0)
426
+ + Object.values(manifest.music).flatMap((t) => Object.values(t.loops)).reduce((a, l) => a + sizeOf(Array.isArray(l) ? l[0] : l), 0);
427
+ const playerSrc = join(HERE, 'player', 'sound.js');
428
+ const playerDest = mode === 'static' ? join(assets, 'sound.js') : join(gameDir, 'src', 'sound.js');
429
+ mkdirSync(dirname(playerDest), { recursive: true });
430
+ copyFileSync(playerSrc, playerDest);
431
+ copyFileSync(join(HERE, 'player', 'sound.d.ts'), playerDest.replace(/\.js$/, '.d.ts'));
432
+ const importLine = mode === 'static'
433
+ ? `<script type="module">import { createSound } from './sound/sound.js'; window.sound = createSound({ base: 'sound/' });</script>`
434
+ : `import { createSound } from './sound.js';\nconst sound = createSound({ base: 'sound/' });`;
435
+ return {
436
+ ok: true, command: 'wire', game, mode, files: copied.length, kb: Math.round(bytes / 1024), manifest: rel(root, join(assets, 'sound.json')), player: rel(root, playerDest),
437
+ sfx: Object.keys(manifest.sfx), music: Object.fromEntries(Object.entries(manifest.music).map(([k, v]) => [k, Object.keys(v.loops)])),
438
+ add: importLine,
439
+ then: 'call sound.play(\'<name>\') where the action happens (the host AND every replica: each browser plays its own sound for what it sees), sound.music(\'<slug>\') once at start, sound.section(\'<name>\') when the game\'s intensity changes. Build, then prove it with the playtest skill (its sound row captures what the game really plays).',
440
+ warnings: seams,
441
+ download: `about ${Math.round(downloadBytes / 1024)} KB for a browser that decodes Ogg (the WAV copies are only fetched when it cannot)`,
442
+ ...(downloadBytes > 3 * 1024 * 1024 ? { note: `${Math.round(downloadBytes / 1024 / 1024)} MB of sound: phones download it before the first round; fewer variants or shorter loops` } : {}),
443
+ };
444
+ }
445
+
446
+ /* ---------------------------------------------------------------- add / publish */
447
+
448
+ function add(root) {
449
+ const slug = pos[1];
450
+ const dir = jobDir(root, slug);
451
+ const sfxInfo = readJson(join(dir, 'sfx.json'), null);
452
+ const plan = readJson(join(dir, 'plan.json'), null);
453
+ const m = readJson(join(dir, 'master.json'), null);
454
+ const title = flags.get('title') ?? plan?.title;
455
+ if (!title || title === true) throw new Error('--title "<title>"');
456
+ const files = [];
457
+ const mp3 = join(dir, `${slug}.mp3`);
458
+ if (!existsSync(mp3)) throw new Error(`music/${slug}/${slug}.mp3 is missing: render it first (sfx, score or mix)`);
459
+ files.push({ role: 'audio', path: rel(root, mp3), type: 'audio/mpeg', bytes: statSync(mp3).size });
460
+ if (existsSync(join(dir, `${slug}-master.wav`))) files.push({ role: 'master', path: rel(root, join(dir, `${slug}-master.wav`)), type: 'audio/wav', public: false });
461
+ for (const f of readdirSync(dir)) {
462
+ const lj = /^(.*-loop-(\d+)bars)\.json$/.exec(f);
463
+ if (!lj) continue;
464
+ const info = readJson(join(dir, f), {});
465
+ const wav = join(dir, `${lj[1]}.wav`);
466
+ if (existsSync(wav)) files.push({ role: 'loop', name: `Loop: ${info.section ?? 'section'}, ${lj[2]} bars`, path: rel(root, wav), type: 'audio/wav', bars: Number(lj[2]), bytes: statSync(wav).size, loopSeconds: info.seconds ?? null });
467
+ }
468
+ if (sfxInfo) for (const e of sfxInfo.effects) { const f = e.files[0]; files.push({ role: 'stem', name: `${e.name} (${e.files.length} variant${e.files.length > 1 ? 's' : ''})`, path: f.file, type: 'audio/wav', bytes: statSync(join(root, f.file)).size }); }
469
+ else if (existsSync(join(dir, 'stems'))) for (const f of readdirSync(join(dir, 'stems')).sort()) files.push({ role: 'stem', name: f.replace(/\.wav$/, ''), path: rel(root, join(dir, 'stems', f)), type: 'audio/wav', bytes: statSync(join(dir, 'stems', f)).size });
470
+ const cover = ['cover.jpg', 'cover.png', 'cover.webp'].map((c) => join(dir, c)).find(existsSync);
471
+ if (cover) files.push({ role: 'cover', path: rel(root, cover) });
472
+ const kind = sfxInfo ? 'sfx' : flags.has('for-game') ? 'score' : 'song';
473
+ const forGame = flags.get('for-game') ?? sfxInfo?.for?.game ?? null;
474
+ const entry = {
475
+ slug, kind, title: String(title), blurb: flags.get('blurb') === true ? '' : String(flags.get('blurb') ?? ''),
476
+ published: flags.has('publish'), duration: m?.seconds ?? null, bpm: plan?.bpm ?? null, key: plan?.key ?? null,
477
+ loudness: m?.mp3 ? { lufs: m.mp3.lufs, truePeak: m.mp3.truePeak } : null, files, lyrics: null,
478
+ credits: flags.get('credits') ?? (sfxInfo ? 'Sound effects synthesized from code by the studio: no samples, no AI model.' : 'Synthesized from a written score by the studio: no samples, no AI model.'),
479
+ rights: { provider: 'none', commercial: null, attribution: null },
480
+ ...(forGame ? { for: { game: String(forGame) } } : {}),
481
+ made: { provider: 'local', model: 'synthesized (the Homie plugin\'s sound skill)', at: new Date().toISOString(), spec: rel(root, join(dir, sfxInfo ? 'sfx.json' : existsSync(join(dir, 'score.json')) ? 'score.json' : 'mix.json')) },
482
+ };
483
+ const saved = upsertEntry(root, 'music', entry);
484
+ return { ok: true, command: 'add', slug, kind, published: saved.published, files: files.map((f) => `${f.role} ${f.path}${f.public === false ? ' (kept off the site)' : ''}`), manifest: 'music/manifest.json' };
485
+ }
486
+
487
+ function publish(root) {
488
+ const slug = pos[1];
489
+ const r = spawnSync(process.execPath, [join(HERE, '..', '..', 'music', 'scripts', 'music.mjs'), 'publish', slug, '--json', ...(flags.has('no-deploy') ? ['--no-deploy'] : [])], { cwd: root, encoding: 'utf8', timeout: 20 * 60_000, maxBuffer: 64 * 1024 * 1024 });
490
+ try { return JSON.parse(r.stdout); } catch { return { ok: false, command: 'publish', why: (r.stdout + r.stderr).trim().split('\n').slice(-3).join(' ') }; }
491
+ }
492
+
493
+ /* ---------------------------------------------------------------- main */
494
+
495
+ async function main() {
496
+ const cmd = pos[0];
497
+ if (!cmd || cmd === 'help' || flags.has('help')) {
498
+ const head = readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0];
499
+ return { ok: true, command: 'help', usage: head.replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '').trim() };
500
+ }
501
+ if (cmd === 'check') return check();
502
+ if (cmd === 'presets') return presets();
503
+ if (cmd === 'analyze') return analyze();
504
+ const root = studioRoot();
505
+ if (cmd === 'sfx') return sfx(root);
506
+ if (cmd === 'score') return score(root);
507
+ if (cmd === 'mix') return mix(root);
508
+ if (cmd === 'wire') return wire(root);
509
+ if (cmd === 'add') return add(root);
510
+ if (cmd === 'publish') return publish(root);
511
+ return { ok: false, command: cmd, why: `unknown command "${cmd}" (sound.mjs help)` };
512
+ }
513
+
514
+ try {
515
+ const r = await main();
516
+ if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
517
+ } catch (error) {
518
+ print({ ok: false, why: error instanceof Error ? error.message : String(error) });
519
+ process.exitCode = 1;
520
+ }
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: style
3
+ description: "A game's look as a set of decisions that fit together — render style, palette, shape language, proportions, materials, light, camera, fonts and effects; then the cast, its library family, scale and phone budgets — each picked automatically from the person's words with a one-line why, which the person can steer (\"warmer\", \"closer\"), lock, or explore on a style board of three directions drawn by the game engine itself (free; a painted mood image only on their own fal account). Locked and pinned decisions keep every asset coherent: change one and the blast radius lists exactly what went stale and what remaking it would cost, and nothing is remade without a yes. Use when someone talks about a game's look or style, \"make it look like…\", \"show me other looks\", \"keep that palette\", \"darker\", \"the art doesn't fit together\", or before making any models or art for a game."
4
+ compatibility: Node 22 and Chrome. The style board is free; a painted mood image uses the creator's own fal account (FAL_KEY), through the models skill.
5
+ metadata:
6
+ providers: fal
7
+ ---
8
+
9
+ # The look of a game: decisions, the board, locks
10
+
11
+ A game's art direction lives in `games/<id>/codex/decisions.json` (private, like the codex) and is drawn in
12
+ the Game Codex's **Art direction** tab: every decision with its value, its why, and its state. It is the style
13
+ bible. Everything made for the game (models, textures, covers, title cards) is made under it and records the
14
+ revision it was made under, so the game stays one game.
15
+
16
+ | State | Meaning | Who changes it |
17
+ | --- | --- | --- |
18
+ | **auto** | your pick, with its why | you, freely |
19
+ | **steered** | the person nudged it ("warmer") | you may refine within the nudge, never undo it |
20
+ | **pinned** | the first asset built on it pinned it (locked by use) | you only while nothing paid or published depends on it; else ask |
21
+ | **locked** | the person froze it | only the person: unlock needs their reason, after they saw the blast radius |
22
+
23
+ A lock protects taste, not money: no signed approval, but you never change a locked value on your own, and you
24
+ record their words when you lock for them.
25
+
26
+ ## The automatic path (the default: no questions)
27
+
28
+ ```sh
29
+ npx --no-install homie-studio style init <id> --prompt "<the person's own words about the game>"
30
+ ```
31
+
32
+ It fills about thirty decisions from their words, the codex and the genre, writes `style.json` (the palette,
33
+ fonts, light and camera the game draws with) and redraws the codex. Say ONE line: "Look: flat low-poly,
34
+ autumn grove palette, golden hour, high three-quarter camera; open the codex to change anything." Then build
35
+ with free routes only (the engine and the starter library: the `models` skill) unless the plan set an art
36
+ budget. The first asset you add pins the decisions it was made under.
37
+
38
+ ## The hands-on path
39
+
40
+ When the person wants to steer the look closely (only when they request it), show the **style board**:
41
+
42
+ ```sh
43
+ npx --no-install homie-studio style board <id>
44
+ ```
45
+
46
+ (In an app with Homie's cards: `style_explore`; the card has Pick, Mix, Steer and Lock.) Three coherent
47
+ directions, each **drawn by the game's own engine**: its palette on a ground and a sky, its light and fog, its
48
+ camera, its material model (flat, toon with ink outlines, painted, PBR, pixel), a character stand-in at the
49
+ decided proportions, and the starter library family's own pieces re-tinted into the palette, with the title in
50
+ its display font. Free, about 20 seconds, saved under `games/<id>/codex/board/`. Look at all three yourself
51
+ before you show them (file_read), and say in one line each what is different.
52
+
53
+ - **Pick**: `style pick <id> b`. **Mix**: `style pick <id> --mix style.palette=b,style.camera=c`.
54
+ - **Steer**: `style steer <id> style.palette "warmer"` (warmer, cooler, less saturated, darker, lighter, a hex
55
+ colour with its job; light: golden hour, night, harder shadows; camera: closer, further, higher, top-down;
56
+ shape: chunkier, sharper; materials: add outlines). Words it does not know are recorded; refine the value
57
+ with `style set` within them.
58
+ - **Lock** when the person says so: `style lock <id> style.palette --words "<what they said>"`, or the whole
59
+ style: `style lock <id> style --words "…"`.
60
+ - **A painted mood image** per direction is optional and PAID (about US$0.035 each, the person's own fal
61
+ account, `models.mjs mood`): a picture the engine cannot draw is a target, never a promise, and the card says
62
+ so. Offer it only when the engine's swatches leave the person unsure.
63
+ - **Golden images**: once the style is locked, two to six approved pictures (a chosen mood image, a concept they
64
+ loved, their own art) become the references of every generated concept: `style golden <id> add <image>`.
65
+
66
+ ## The characters: the cast card
67
+
68
+ `cast_plan` (or `npx --no-install homie-studio cast <id>`) shows the decisions that shape characters together:
69
+ proportions (heads tall, metres), the shape language and its silhouette rule (every character readable black on white
70
+ at 64 px: the lineup checks it), the palette, the library family, the skeleton family (`rig.skeleton`) and where rigs
71
+ come from; then every character made or planned, with its source, skeleton, bones, triangles and clips. A library
72
+ character comes in free (`asset_add`); a generated one through the `models` skill's `character` (paid, priced first);
73
+ their clips and feel are the `animate` skill's. A starter game's own `style.json` is what `style init` starts from:
74
+ it never repaints a working game behind its back.
75
+
76
+ ## Changing a locked decision: the blast radius first
77
+
78
+ When the person wants a locked or pinned decision changed, never just change it:
79
+
80
+ ```sh
81
+ npx --no-install homie-studio style blast <id> style.palette # what goes stale, and the cost
82
+ npx --no-install homie-studio style set <id> style.palette <value> --unlock --reason "<their words>" # shows it again, changes nothing
83
+ npx --no-install homie-studio style set <id> style.palette <value> --unlock --reason "<their words>" --confirm # after their yes
84
+ ```
85
+
86
+ Tell them the list in plain words: which assets go stale, which are fixed for free (a palette re-tint of
87
+ flat-coloured models, another library match, a procedural one regenerated) and what remaking the generated ones
88
+ would cost. Nothing is remade by itself: remaking is a new, priced job they approve (the `models` skill).
89
+
90
+ ## One look end to end
91
+
92
+ - The game draws with `games/<id>/style.json` (palette, fonts, light, camera): read it rather than hard-coding
93
+ colours, so a change of the palette reaches the HUD and the world.
94
+ - The codex wears a steered or locked palette and fonts; the landing and the trailer's title cards use them too
95
+ (a game with no `landing.theme` takes its accent and glow from `style.json`).
96
+ - `style prompt <id>` is the derived style prompt (about 80 words: medium, shape, palette hexes, light, framing)
97
+ every generated picture starts from. It is computed, never edited by hand.
98
+
99
+ ## Never
100
+
101
+ - Never change a locked decision without the person's yes after the blast radius; never remake anything by
102
+ yourself; never lock on your own.
103
+ - Never present a painted mood image as what the game looks like.
104
+ - Never name a living artist in a style ("in the style of …"): describe the look in words.
105
+ - Never "upgrade" a game whose procedural look is its strength to generated meshes without the owner's ask.