@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.
- package/CHANGELOG.md +78 -1
- package/README.md +55 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +52 -3
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +25 -2
- package/lib/check.mjs +3 -1
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/office.mjs +30 -8
- package/lib/optimise.mjs +380 -0
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +32 -1
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +75 -0
- package/lib/thumb.mjs +82 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/package.json +14 -2
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2086 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/index.mjs +5 -3
- package/worker/pages.mjs +13 -5
- package/worker/qr.mjs +11 -0
- package/worker/site.mjs +5 -3
- 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
|
+
}
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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
|
}
|