@homie-rocks/studio 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +55 -0
  3. package/assets/assets.ts +500 -0
  4. package/assets/render-page.ts +629 -0
  5. package/assets/safety.mjs +194 -0
  6. package/bin/homie-studio.mjs +52 -3
  7. package/lib/art-cli.mjs +296 -0
  8. package/lib/art-tools.mjs +331 -0
  9. package/lib/asset-check.mjs +189 -0
  10. package/lib/asset-import.mjs +136 -0
  11. package/lib/asset-manifest.mjs +318 -0
  12. package/lib/build.mjs +25 -2
  13. package/lib/check.mjs +3 -1
  14. package/lib/codex.mjs +43 -1
  15. package/lib/colour.mjs +160 -0
  16. package/lib/decisions.mjs +800 -0
  17. package/lib/directory.mjs +9 -1
  18. package/lib/library.mjs +169 -0
  19. package/lib/mcp-tools.mjs +22 -4
  20. package/lib/mcp.mjs +6 -3
  21. package/lib/office.mjs +30 -8
  22. package/lib/optimise.mjs +380 -0
  23. package/lib/port-check.mjs +3 -1
  24. package/lib/progress.mjs +16 -1
  25. package/lib/remix-assets.mjs +84 -0
  26. package/lib/render3d.mjs +81 -0
  27. package/lib/scaffold.mjs +32 -1
  28. package/lib/site.mjs +6 -1
  29. package/lib/studio.mjs +136 -9
  30. package/lib/style-board.mjs +227 -0
  31. package/lib/style-presets.mjs +121 -0
  32. package/lib/template-history.json +75 -0
  33. package/lib/thumb.mjs +82 -0
  34. package/mcp/ui/card.css +15 -0
  35. package/mcp/ui/cast.js +62 -0
  36. package/mcp/ui/decision.js +61 -0
  37. package/mcp/ui/lineup.js +68 -0
  38. package/mcp/ui/rights.js +30 -0
  39. package/mcp/ui/style.js +154 -0
  40. package/package.json +14 -2
  41. package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
  42. package/starters/gem-rush-3d/assets/manifest.json +2318 -0
  43. package/starters/gem-rush-3d/credits.json +31 -0
  44. package/starters/gem-rush-3d/game.json +11 -0
  45. package/starters/gem-rush-3d/index.html +20 -0
  46. package/starters/gem-rush-3d/lab.json +19 -0
  47. package/starters/gem-rush-3d/src/main.ts +2086 -0
  48. package/starters/gem-rush-3d/style.json +66 -0
  49. package/starters/gem-rush-3d/tunables.json +13 -0
  50. package/worker/index.mjs +5 -3
  51. package/worker/pages.mjs +13 -5
  52. package/worker/qr.mjs +11 -0
  53. package/worker/site.mjs +5 -3
  54. package/worker/version.mjs +1 -1
@@ -0,0 +1,194 @@
1
+ /**
2
+ * IS THIS MODEL FILE SAFE TO LOAD? One answer for every place a model enters a game: the browser loader
3
+ * (assets/assets.ts, before three.js parses a byte), `homie-studio assets add` and `assets check` (Node), and the
4
+ * starter library's build. Plain JavaScript with no imports, so a browser bundle and Node read the same rules.
5
+ *
6
+ * A model is a glTF 2.0 binary (.glb): a 12-byte header, a JSON chunk and one binary chunk. It is refused when:
7
+ * - it is not a GLB (a bare .gltf names its buffers and pictures by address: that is an external file);
8
+ * - any buffer or picture names a `uri` other than a small `data:` one (an external URI could be fetched from
9
+ * anywhere, at any size, by every player's browser);
10
+ * - the file, its declared buffers, a buffer view or an accessor is bigger than the limits (a 200 MB buffer would
11
+ * take a phone's whole page memory before anything checked it), or an accessor reaches past its buffer;
12
+ * - it requires an extension three.js cannot read, or names more meshes, nodes, pictures or animations than any
13
+ * game asset needs.
14
+ * Warnings (extensions used but not required, a picture type a phone may not decode) never refuse.
15
+ */
16
+
17
+ /** The limits, in bytes and counts: `LIMITS.game` for a shipped asset, `LIMITS.import` for a raw file coming in. */
18
+ export const LIMITS = Object.freeze({
19
+ game: Object.freeze({ bytes: 5 * 1024 * 1024, json: 2 * 1024 * 1024, buffer: 5 * 1024 * 1024, dataUri: 256 * 1024, vertices: 500_000, meshes: 512, nodes: 4096, materials: 64, images: 32, textures: 64, animations: 256, skins: 16, accessors: 8192 }),
20
+ import: Object.freeze({ bytes: 64 * 1024 * 1024, json: 8 * 1024 * 1024, buffer: 64 * 1024 * 1024, dataUri: 1024 * 1024, vertices: 4_000_000, meshes: 4096, nodes: 16384, materials: 256, images: 128, textures: 256, animations: 1024, skins: 64, accessors: 32768 }),
21
+ });
22
+
23
+ /** Extensions three.js's GLTFLoader reads (r185). A file that REQUIRES any other is refused. */
24
+ export const READABLE_EXTENSIONS = Object.freeze([
25
+ 'KHR_meshopt_compression', 'EXT_meshopt_compression', 'KHR_mesh_quantization', 'KHR_texture_basisu', 'EXT_texture_webp',
26
+ 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_materials_unlit', 'KHR_materials_emissive_strength', 'KHR_materials_clearcoat',
27
+ 'KHR_materials_ior', 'KHR_materials_specular', 'KHR_materials_transmission', 'KHR_materials_volume', 'KHR_materials_sheen',
28
+ 'KHR_materials_iridescence', 'KHR_materials_anisotropy', 'KHR_materials_dispersion', 'KHR_lights_punctual', 'EXT_mesh_gpu_instancing',
29
+ 'KHR_draco_mesh_compression',
30
+ ]);
31
+
32
+ const PICTURE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/ktx2', 'image/avif'];
33
+ const COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
34
+ const TYPE_SIZE = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
35
+ const MAGIC = 0x46546c67; // "glTF"
36
+ const JSON_CHUNK = 0x4e4f534a;
37
+ const BIN_CHUNK = 0x004e4942;
38
+
39
+ const view = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
40
+ const size = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
41
+
42
+ /** The bytes a `data:` URI decodes to, without decoding it. */
43
+ function dataUriBytes(uri) {
44
+ const comma = uri.indexOf(',');
45
+ if (comma < 0) return Infinity;
46
+ const head = uri.slice(5, comma);
47
+ const body = uri.length - comma - 1;
48
+ return /;base64$/i.test(head) ? Math.floor((body * 3) / 4) : body;
49
+ }
50
+
51
+ /**
52
+ * Read a GLB's header and chunks: { json, bin: { offset, length } | null } or throws a plain-words Error.
53
+ * `bytes` is a Uint8Array (a Node Buffer is one).
54
+ */
55
+ export function readGlb(bytes, { maxJson = LIMITS.import.json } = {}) {
56
+ if (!(bytes instanceof Uint8Array)) throw new Error('not a file (expected bytes)');
57
+ if (bytes.byteLength < 20) throw new Error('too short to be a glTF binary (.glb)');
58
+ const dv = view(bytes);
59
+ if (dv.getUint32(0, true) !== MAGIC) {
60
+ const head = new TextDecoder().decode(bytes.subarray(0, Math.min(64, bytes.byteLength))).trimStart();
61
+ throw new Error(head.startsWith('{') ? 'a .gltf with separate files (its buffers and pictures are loaded by address): only a self-contained .glb is loaded' : 'not a glTF binary (.glb)');
62
+ }
63
+ const version = dv.getUint32(4, true);
64
+ if (version !== 2) throw new Error(`glTF version ${version} (only 2 is read)`);
65
+ const length = dv.getUint32(8, true);
66
+ if (length > bytes.byteLength) throw new Error(`the header says ${size(length)} but the file has ${size(bytes.byteLength)}: it is cut short`);
67
+ let at = 12;
68
+ let json = null;
69
+ let bin = null;
70
+ while (at + 8 <= length) {
71
+ const chunkLength = dv.getUint32(at, true);
72
+ const type = dv.getUint32(at + 4, true);
73
+ const start = at + 8;
74
+ if (start + chunkLength > length) throw new Error('a chunk runs past the end of the file');
75
+ if (type === JSON_CHUNK && json === null) {
76
+ if (chunkLength > maxJson) throw new Error(`its JSON is ${size(chunkLength)}, over ${size(maxJson)}`);
77
+ try { json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, start + chunkLength))); } catch { throw new Error('its JSON chunk is not JSON'); }
78
+ } else if (type === BIN_CHUNK && bin === null) bin = { offset: start, length: chunkLength };
79
+ at = start + chunkLength + ((4 - (chunkLength % 4)) % 4);
80
+ }
81
+ if (!json || typeof json !== 'object') throw new Error('it has no JSON chunk');
82
+ return { json, bin, length };
83
+ }
84
+
85
+ /**
86
+ * Check a GLB against `limits` (LIMITS.game by default). Returns
87
+ * { ok, problems: [plain words], warnings: [plain words], info: { bytes, triangles, vertices, meshes, nodes,
88
+ * materials, images, textures, animations, skins, joints, extensions: { used, required } } }
89
+ * Never throws: a file it cannot read is `ok: false` with the reason.
90
+ */
91
+ export function checkGlb(bytes, limits = LIMITS.game) {
92
+ const L = { ...LIMITS.game, ...limits };
93
+ const problems = [];
94
+ const warnings = [];
95
+ const info = { bytes: bytes?.byteLength ?? 0, triangles: 0, vertices: 0, meshes: 0, nodes: 0, materials: 0, images: 0, textures: 0, animations: 0, skins: 0, joints: 0, extensions: { used: [], required: [] } };
96
+ if (info.bytes > L.bytes) problems.push(`the file is ${size(info.bytes)}, over the ${size(L.bytes)} a model may be here`);
97
+ let glb;
98
+ try { glb = readGlb(bytes, { maxJson: L.json }); } catch (error) { problems.push(error.message); return { ok: false, problems, warnings, info }; }
99
+ const j = glb.json;
100
+ const arr = (k) => (Array.isArray(j[k]) ? j[k] : []);
101
+ const v = String(j.asset?.version ?? '');
102
+ if (!/^2\./.test(v)) problems.push(`asset.version is "${v || 'missing'}" (glTF 2.x is read)`);
103
+
104
+ // Counts.
105
+ const counts = { meshes: arr('meshes').length, nodes: arr('nodes').length, materials: arr('materials').length, images: arr('images').length, textures: arr('textures').length, animations: arr('animations').length, skins: arr('skins').length, accessors: arr('accessors').length };
106
+ Object.assign(info, { meshes: counts.meshes, nodes: counts.nodes, materials: counts.materials, images: counts.images, textures: counts.textures, animations: counts.animations, skins: counts.skins });
107
+ for (const [k, n] of Object.entries(counts)) if (L[k] !== undefined && n > L[k]) problems.push(`${n} ${k} (at most ${L[k]})`);
108
+
109
+ // Extensions.
110
+ const used = arr('extensionsUsed').map(String);
111
+ const required = arr('extensionsRequired').map(String);
112
+ info.extensions = { used, required };
113
+ for (const e of required) if (!READABLE_EXTENSIONS.includes(e)) problems.push(`it requires the extension ${e}, which three.js cannot read`);
114
+ for (const e of used) if (!required.includes(e) && !READABLE_EXTENSIONS.includes(e)) warnings.push(`it uses ${e}, which three.js ignores`);
115
+ if (required.includes('KHR_draco_mesh_compression')) warnings.push('Draco geometry: the decoder is about 250 KB more to download; meshopt is the house format');
116
+
117
+ // Buffers: only the GLB's own binary chunk, or a small data: URI. Never an address.
118
+ let declared = 0;
119
+ arr('buffers').forEach((b, i) => {
120
+ const len = Number(b?.byteLength);
121
+ if (!Number.isFinite(len) || len < 0) { problems.push(`buffer ${i} has no byteLength`); return; }
122
+ // A meshopt fallback buffer (no uri, never loaded by a reader that decodes meshopt) holds nothing in the file.
123
+ const fallback = b.uri === undefined && (b.extensions?.EXT_meshopt_compression?.fallback === true || b.extensions?.KHR_meshopt_compression?.fallback === true);
124
+ if (fallback) { if (len > L.buffer * 4) problems.push(`buffer ${i} (a meshopt fallback) declares ${size(len)}`); return; }
125
+ declared += len;
126
+ if (len > L.buffer) problems.push(`buffer ${i} declares ${size(len)}, over ${size(L.buffer)}`);
127
+ if (typeof b.uri === 'string') {
128
+ if (!/^data:/i.test(b.uri)) problems.push(`buffer ${i} is loaded from an address (${b.uri.slice(0, 60)}): an external URI`);
129
+ else if (dataUriBytes(b.uri) > L.dataUri) problems.push(`buffer ${i} is a data: URI of ${size(dataUriBytes(b.uri))}, over ${size(L.dataUri)}`);
130
+ } else if (i === 0) {
131
+ if (!glb.bin) problems.push('buffer 0 has no binary chunk to point at');
132
+ else if (len > glb.bin.length) problems.push(`buffer 0 declares ${size(len)} but the binary chunk holds ${size(glb.bin.length)}`);
133
+ } else problems.push(`buffer ${i} has no uri and is not the binary chunk`);
134
+ });
135
+ if (declared > L.bytes) problems.push(`its buffers declare ${size(declared)} in all, over ${size(L.bytes)}`);
136
+ const bufLen = (i) => Number(arr('buffers')[i]?.byteLength ?? -1);
137
+
138
+ // Buffer views and accessors stay inside what was declared.
139
+ const views = arr('bufferViews');
140
+ views.forEach((bv, i) => {
141
+ const end = Number(bv?.byteOffset ?? 0) + Number(bv?.byteLength ?? NaN);
142
+ if (!Number.isFinite(end) || bufLen(Number(bv?.buffer)) < 0 || end > bufLen(Number(bv.buffer))) problems.push(`buffer view ${i} reaches past its buffer`);
143
+ });
144
+ let maxCount = 0;
145
+ arr('accessors').forEach((a, i) => {
146
+ const count = Number(a?.count);
147
+ if (!Number.isInteger(count) || count < 0) { problems.push(`accessor ${i} has no count`); return; }
148
+ maxCount = Math.max(maxCount, count);
149
+ if (count > L.vertices * 3) problems.push(`accessor ${i} holds ${count.toLocaleString('en-US')} elements`);
150
+ if (a.bufferView === undefined) return;
151
+ const bv = views[Number(a.bufferView)];
152
+ const el = (COMPONENT_BYTES[a.componentType] ?? 0) * (TYPE_SIZE[a.type] ?? 0);
153
+ if (!bv || !el) { problems.push(`accessor ${i} points at nothing it can read`); return; }
154
+ const stride = Number(bv.byteStride ?? 0) || el;
155
+ const need = Number(a.byteOffset ?? 0) + (count ? stride * (count - 1) + el : 0);
156
+ // A meshopt-compressed view holds its decoded size in its extension; the plain view is the fallback.
157
+ const decoded = Number(bv.extensions?.EXT_meshopt_compression?.byteLength ?? bv.extensions?.KHR_meshopt_compression?.byteLength ?? bv.byteLength);
158
+ if (need > Math.max(Number(bv.byteLength), decoded)) problems.push(`accessor ${i} reaches past its buffer view`);
159
+ });
160
+
161
+ // Pictures: inside the file (a buffer view), or a small data: URI. Never an address.
162
+ arr('images').forEach((im, i) => {
163
+ if (typeof im?.uri === 'string') {
164
+ if (!/^data:/i.test(im.uri)) problems.push(`picture ${i} is loaded from an address (${im.uri.slice(0, 60)}): an external URI`);
165
+ else if (dataUriBytes(im.uri) > L.dataUri) problems.push(`picture ${i} is a data: URI of ${size(dataUriBytes(im.uri))}, over ${size(L.dataUri)}`);
166
+ } else if (im?.bufferView === undefined) problems.push(`picture ${i} has neither a buffer view nor a uri`);
167
+ const type = String(im?.mimeType ?? (typeof im?.uri === 'string' ? /^data:([^;,]+)/.exec(im.uri)?.[1] ?? '' : ''));
168
+ if (type && !PICTURE_TYPES.includes(type)) warnings.push(`picture ${i} is ${type}`);
169
+ });
170
+
171
+ // Triangles and vertices, as drawn: each mesh times the nodes that place it (GPU instancing counted once per node).
172
+ const accessors = arr('accessors');
173
+ const meshTris = arr('meshes').map((m) => (Array.isArray(m?.primitives) ? m.primitives : []).reduce((n, p) => {
174
+ const mode = p?.mode ?? 4;
175
+ const count = p?.indices !== undefined ? Number(accessors[p.indices]?.count ?? 0) : Number(accessors[p?.attributes?.POSITION]?.count ?? 0);
176
+ return n + (mode === 4 ? Math.floor(count / 3) : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0);
177
+ }, 0));
178
+ const meshVerts = arr('meshes').map((m) => (Array.isArray(m?.primitives) ? m.primitives : []).reduce((n, p) => n + Number(accessors[p?.attributes?.POSITION]?.count ?? 0), 0));
179
+ for (const node of arr('nodes')) {
180
+ if (node?.mesh === undefined) continue;
181
+ const inst = Number(accessors[node.extensions?.EXT_mesh_gpu_instancing?.attributes?.TRANSLATION]?.count ?? 1) || 1;
182
+ info.triangles += (meshTris[node.mesh] ?? 0) * inst;
183
+ info.vertices += (meshVerts[node.mesh] ?? 0) * inst;
184
+ }
185
+ info.joints = arr('skins').reduce((n, s) => Math.max(n, Array.isArray(s?.joints) ? s.joints.length : 0), 0);
186
+ if (info.vertices > L.vertices) problems.push(`${info.vertices.toLocaleString('en-US')} vertices drawn (at most ${L.vertices.toLocaleString('en-US')})`);
187
+ void maxCount;
188
+ return { ok: problems.length === 0, problems, warnings, info };
189
+ }
190
+
191
+ /** One line for a refusal: "refused <label>: a; b". */
192
+ export function refusal(label, result) {
193
+ return `refused ${label}: ${result.problems.slice(0, 4).join('; ')}${result.problems.length > 4 ? `; and ${result.problems.length - 4} more` : ''}`;
194
+ }
@@ -94,8 +94,9 @@
94
94
  * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>] [--server <id>]
95
95
  * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
96
96
  * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
97
+ * homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
97
98
  * homie-studio office close <game> <room> [--minutes 10] [--reopen]
98
- * (kick, close and launch are ASKED for: the owner confirms each with one
99
+ * (kick, mute, close and launch are ASKED for: the owner confirms each with one
99
100
  * tap in their own browser, from the link this prints)
100
101
  * homie-studio office revoke (every office key, play ticket and pending ask ends)
101
102
  *
@@ -162,6 +163,31 @@
162
163
  * Claude artifact; --open: open it in this computer's browser
163
164
  * homie-studio codex link <id> a one-time link to the codex on the live site (a private page for the owner)
164
165
  *
166
+ * homie-studio style init <id> [--prompt "<the person's words>"] [--hands-on] [--budget <usd>]
167
+ * art direction as decisions (games/<id>/codex/decisions.json): render style,
168
+ * palette, shape, proportions, materials, light, camera, fonts, effects; the cast,
169
+ * its routes, library family, scale and budgets; rigs, animation, in-game budgets.
170
+ * Each starts as an automatic pick with a why; the first asset built on one pins it
171
+ * homie-studio style [show] <id> [--phase style] the decisions (· auto, ~ steered, ● pinned by use, ■ locked)
172
+ * homie-studio style set|steer|lock|unlock <id> <decision> … change one ("warmer"), lock it for the person (--words
173
+ * "<what they said>"); a locked one changes only with --unlock --reason, and
174
+ * --confirm after the person saw what goes stale (style blast)
175
+ * homie-studio style board <id> three directions drawn by the engine (free): swatches in codex/board/
176
+ * homie-studio style pick <id> <a|b|c> [--mix style.palette=b] the person's pick (or a mix) from the board
177
+ * homie-studio style mood|golden|blast|prompt <id> … a paid mood image's record, golden images, the blast radius
178
+ * of a change, the derived style prompt every generation starts from
179
+ * homie-studio assets [list] <id> every asset: route, licence, measurements, spend (games/<id>/assets/manifest.json)
180
+ * homie-studio assets find "<words>" [--kind prop] [--family kenney] the free CC0 starter library (HOMIE_LIBRARY: a copy)
181
+ * homie-studio assets add <id> <library item> [--height <m>] | --file <model> --license <kind> [--attribution "…"]
182
+ * copied in, checked (no external URIs, no oversized files), made phone-sized,
183
+ * recorded with its licence; RIGHTS.md and credits.json follow
184
+ * homie-studio assets redo <id> <asset> made again from its kept raw file, free (today's budgets and style)
185
+ * homie-studio assets optimise <in> --out <file> [--triangles 1500] [--texture 512] [--height <m>]
186
+ * homie-studio assets check <id> phone budgets (triangles, draw calls, picture memory, download), the
187
+ * glTF-Validator, licences, stale assets, big files in git
188
+ * homie-studio assets lineup|review|rights|stale|remove <id> … the lineup (true scale, silhouettes, palette drift),
189
+ * a reviewer's score, RIGHTS.md, what a changed decision made stale
190
+ *
165
191
  * homie-studio statusline the current build in one line (what Claude Code's status line shows)
166
192
  * homie-studio statusline --install [--project <folder>]
167
193
  * turn it on in Claude Code for this studio (.claude/settings.local.json);
@@ -192,7 +218,7 @@ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, star
192
218
  import { STUDIO_VERSION } from '../lib/version.mjs';
193
219
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
194
220
  import { playersOwner, playersShow } from '../lib/players.mjs';
195
- import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
221
+ import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
196
222
  import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
197
223
  import { AgentSeat } from '../lib/agent-seat.mjs';
198
224
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
@@ -202,11 +228,12 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
202
228
  import { restartWithProxy } from '../lib/net.mjs';
203
229
  import { demoGames, formatDemo } from '../lib/demo.mjs';
204
230
  import { serveMcp } from '../lib/mcp.mjs';
231
+ import { artLines, assetsCommand, styleCommand } from '../lib/art-cli.mjs';
205
232
  import { formatHandoff, handoff } from '../lib/handoff.mjs';
206
233
 
207
234
  const argv = process.argv.slice(2);
208
235
  const flags = new Map();
209
- const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile'];
236
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged'];
210
237
  const positional = [];
211
238
  for (let i = 0; i < argv.length; i++) {
212
239
  const a = argv[i];
@@ -225,15 +252,27 @@ function print(result) {
225
252
  if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
226
253
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
227
254
  const lines = [];
255
+ if (/^(style|assets)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
228
256
  switch (result.command) {
229
257
  case 'new':
230
258
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
231
259
  break;
232
260
  case 'game remix':
233
261
  lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Its game.json credits the original: "${result.credit}"${result.page ? ` (${result.page})` : ''}, on its landing and in its credits. Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
262
+ if (result.assets?.carried) lines.push(` assets: ${result.assets.fetched.length} carried from the original (checked by SHA-256)${result.assets.placeholders.length ? `, ${result.assets.placeholders.length} grey placeholder(s) where the licence does not let a remix carry them (${result.assets.placeholders.map((p) => p.asset).join(', ')}): see games/${result.id}/assets/RIGHTS.md` : ''}`);
263
+ if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
264
+ for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
265
+ if (result.needsNotAdded?.length) lines.push(`Its game.json also asks for ${result.needsNotAdded.join(', ')}: not added (a remix adds only what the toolkit's starters use). Read the code before adding any.`);
234
266
  break;
235
267
  case 'game new':
236
268
  lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
269
+ if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
270
+ for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
271
+ if (result.models) {
272
+ const m = result.models;
273
+ lines.push(`Models: ${m.fetched} from the starter library (${m.from}) into games/${result.id}/public/models/, each checked by SHA-256.`);
274
+ if (m.missing.length) lines.push(` ${m.missing.length} not here${m.why ? ` (${m.why})` : `: ${m.missing.map((x) => `${x.asset} (${x.why})`).join(', ')}`}. The game draws stand-ins for them; once the library is reachable, homie-studio assets add ${result.id} <item> --as <asset> brings each in (assets/manifest.json names them).`);
275
+ }
237
276
  break;
238
277
  case 'dev stop':
239
278
  lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
@@ -411,6 +450,7 @@ function print(result) {
411
450
  case 'agents brain':
412
451
  case 'office launch':
413
452
  case 'office kick':
453
+ case 'office mute':
414
454
  case 'office close':
415
455
  lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
416
456
  break;
@@ -571,7 +611,11 @@ async function main() {
571
611
  if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
572
612
  return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal') ?? DEFAULT_GOAL), min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
573
613
  }
614
+ // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
615
+ if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
574
616
  const root = requireStudio();
617
+ if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
618
+ if (cmd === 'assets') return assetsCommand(root, sub, positional, flags, { log });
575
619
  if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
576
620
  if (cmd === 'codex') return codexCommand(root, sub);
577
621
  if (cmd === 'progress') return progressCommand(root, sub);
@@ -612,6 +656,10 @@ async function main() {
612
656
  const game = positional[1] ?? listGames(root)[0]?.id;
613
657
  const url = flags.get('url') ?? siteUrl(root);
614
658
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
659
+ // A big binary committed under games/ fails the check before any browser starts: it belongs in R2, never in git.
660
+ const { bigCommittedFiles } = await import('../lib/asset-check.mjs');
661
+ const big = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${game}/`));
662
+ if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached), then check again.` };
615
663
  return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
616
664
  }
617
665
  if (cmd === 'look') {
@@ -654,6 +702,7 @@ async function main() {
654
702
  if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days'), server: flags.get('server') });
655
703
  if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
656
704
  if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
705
+ if (sub === 'mute') return officeMute(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes'), off: flags.has('off') });
657
706
  if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
658
707
  if (sub === 'revoke') return officeRevoke(root, { url });
659
708
  }