@homie-rocks/studio 0.21.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 +39 -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 +47 -1
- 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/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/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +26 -0
- 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 +38 -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
|
@@ -163,6 +163,31 @@
|
|
|
163
163
|
* Claude artifact; --open: open it in this computer's browser
|
|
164
164
|
* homie-studio codex link <id> a one-time link to the codex on the live site (a private page for the owner)
|
|
165
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
|
+
*
|
|
166
191
|
* homie-studio statusline the current build in one line (what Claude Code's status line shows)
|
|
167
192
|
* homie-studio statusline --install [--project <folder>]
|
|
168
193
|
* turn it on in Claude Code for this studio (.claude/settings.local.json);
|
|
@@ -203,11 +228,12 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
|
203
228
|
import { restartWithProxy } from '../lib/net.mjs';
|
|
204
229
|
import { demoGames, formatDemo } from '../lib/demo.mjs';
|
|
205
230
|
import { serveMcp } from '../lib/mcp.mjs';
|
|
231
|
+
import { artLines, assetsCommand, styleCommand } from '../lib/art-cli.mjs';
|
|
206
232
|
import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
207
233
|
|
|
208
234
|
const argv = process.argv.slice(2);
|
|
209
235
|
const flags = new Map();
|
|
210
|
-
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'];
|
|
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'];
|
|
211
237
|
const positional = [];
|
|
212
238
|
for (let i = 0; i < argv.length; i++) {
|
|
213
239
|
const a = argv[i];
|
|
@@ -226,15 +252,27 @@ function print(result) {
|
|
|
226
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; }
|
|
227
253
|
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
228
254
|
const lines = [];
|
|
255
|
+
if (/^(style|assets)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
|
|
229
256
|
switch (result.command) {
|
|
230
257
|
case 'new':
|
|
231
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);
|
|
232
259
|
break;
|
|
233
260
|
case 'game remix':
|
|
234
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.`);
|
|
235
266
|
break;
|
|
236
267
|
case 'game new':
|
|
237
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
|
+
}
|
|
238
276
|
break;
|
|
239
277
|
case 'dev stop':
|
|
240
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'}.`);
|
|
@@ -573,7 +611,11 @@ async function main() {
|
|
|
573
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]' };
|
|
574
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) : [] });
|
|
575
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 });
|
|
576
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 });
|
|
577
619
|
if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
|
|
578
620
|
if (cmd === 'codex') return codexCommand(root, sub);
|
|
579
621
|
if (cmd === 'progress') return progressCommand(root, sub);
|
|
@@ -614,6 +656,10 @@ async function main() {
|
|
|
614
656
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
615
657
|
const url = flags.get('url') ?? siteUrl(root);
|
|
616
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.` };
|
|
617
663
|
return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
|
|
618
664
|
}
|
|
619
665
|
if (cmd === 'look') {
|
package/lib/art-cli.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio style …` and `homie-studio assets …` (bin/homie-studio.mjs hands both here): art direction as decisions,
|
|
3
|
+
* the style board, the asset list with its licences, the checks and the lineup. Every command that changes something
|
|
4
|
+
* also writes .studio/art/<id>/latest.json, the summary the Studio mod's Art tab and the cards read.
|
|
5
|
+
*
|
|
6
|
+
* style init <id> [--prompt "<the person's words>"] [--hands-on] [--budget <usd>]
|
|
7
|
+
* style [show] <id> [--phase style|cast|rigs|animations|game]
|
|
8
|
+
* style set <id> <decision> <value> [--by person] [--why "…"] [--unlock --reason "…" [--confirm]]
|
|
9
|
+
* style steer <id> <decision> "<words>" ("warmer", "less saturated", "closer", "golden hour", …)
|
|
10
|
+
* style lock <id> <decision>|style --words "<what the person said>"
|
|
11
|
+
* style unlock <id> <decision> --reason "<why>"
|
|
12
|
+
* style board <id> [--prompt "…"] three directions drawn by the engine (free)
|
|
13
|
+
* style pick <id> <a|b|c> [--mix style.palette=b,style.camera=c]
|
|
14
|
+
* style mood <id> <a|b|c> --image <file> [--usd <n>] [--receipt <file>] [--model <endpoint>]
|
|
15
|
+
* style golden <id> add <image> | list
|
|
16
|
+
* style blast <id> <decision> what changing it makes stale, and what remaking costs
|
|
17
|
+
* style prompt <id> the derived style prompt every generation call starts from
|
|
18
|
+
*
|
|
19
|
+
* assets [list] <id>
|
|
20
|
+
* assets find "<words>" [--kind prop] [--family kenney] [--limit 12]
|
|
21
|
+
* assets add <id> <library item> [--as <id>] [--height <m>] [--card "Items/Berry"]
|
|
22
|
+
* assets add <id> --file <model> --license <kind> [--attribution "…"] [--kind prop] [--as <id>] [--height <m>]
|
|
23
|
+
* [--triangles <n>] [--texture <px>] [--route imported|generated] [--steps <steps.json>] [--concept <image>]
|
|
24
|
+
* assets optimise <in> --out <file> [--triangles 1500] [--texture 512] [--height <m>]
|
|
25
|
+
* assets check <id> budgets, validator, licences, staleness, scene totals, big files
|
|
26
|
+
* assets lineup <id> the lineup, silhouettes and palette drift (pictures in .studio/art/<id>/)
|
|
27
|
+
* assets review <id> --score <1-10> [--outliers a,b] [--note "…"]
|
|
28
|
+
* assets rights <id> RIGHTS.md and credits.json written from the manifest
|
|
29
|
+
* assets stale <id> assets made under an older decision
|
|
30
|
+
* assets redo <id> <asset> made again from its kept raw file (free): today's budgets and render style
|
|
31
|
+
* assets remove <id> <asset>
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { join, resolve } from 'node:path';
|
|
35
|
+
import { licenceProblems, readManifest, removeAsset, rightsMarkdown, syncCredits, writeRights } from './asset-manifest.mjs';
|
|
36
|
+
import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
|
|
37
|
+
import { listGames } from './studio.mjs';
|
|
38
|
+
import { checkLines } from './asset-check.mjs';
|
|
39
|
+
|
|
40
|
+
/** The game a command means: the one named, else the only one. */
|
|
41
|
+
function gameOf(root, id, command) {
|
|
42
|
+
const games = listGames(root);
|
|
43
|
+
const planned = id && existsSync(join(root, 'games', id, 'CODEX.md'));
|
|
44
|
+
if (id && (games.some((g) => g.id === id) || planned)) return id;
|
|
45
|
+
if (!id && games.length === 1) return games[0].id;
|
|
46
|
+
throw new Error(`name the game: homie-studio ${command} <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Spend on a game's art, from the receipts (art/receipts.jsonl lines naming the game: every paid call, a redo too),
|
|
51
|
+
* against the cap the person agreed to (art/<game>-models/budget.json, else the decisions' art budget). A studio
|
|
52
|
+
* whose older receipts name no game falls back to its assets' recorded steps.
|
|
53
|
+
*/
|
|
54
|
+
export function artSpend(root, id) {
|
|
55
|
+
const doc = readDecisions(root, id);
|
|
56
|
+
const items = [];
|
|
57
|
+
try {
|
|
58
|
+
for (const line of readFileSync(join(root, 'art', 'receipts.jsonl'), 'utf8').split('\n')) {
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
let r = null;
|
|
61
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
62
|
+
if (r?.game === id && Number(r.cost) > 0 && r.unit === 'usd') items.push({ what: r.what ?? r.artifact ?? r.model, usd: Number(r.cost), receipt: r.requestId ?? null, at: r.at });
|
|
63
|
+
}
|
|
64
|
+
} catch { /* no receipts yet */ }
|
|
65
|
+
if (!items.length) {
|
|
66
|
+
for (const a of readManifest(root, id).assets) for (const s of a.made?.steps ?? []) if (Number(s.usd) > 0) items.push({ what: `${a.id}: ${s.what}`, usd: Number(s.usd), receipt: s.receipt ?? null });
|
|
67
|
+
for (const d of doc?.board?.directions ?? []) if (Number(d.mood?.usd) > 0) items.push({ what: `mood image ${d.id}`, usd: Number(d.mood.usd), receipt: d.mood.receipt ?? null });
|
|
68
|
+
}
|
|
69
|
+
let cap = doc?.budget?.usd ?? null;
|
|
70
|
+
try { const b = JSON.parse(readFileSync(join(root, 'art', `${id}-models`, 'budget.json'), 'utf8')); if (Number.isFinite(Number(b.cap))) cap = Number(b.cap); } catch { /* the decisions' budget */ }
|
|
71
|
+
return { used: +items.reduce((n, x) => n + x.usd, 0).toFixed(3), cap, items };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** .studio/art/<id>/latest.json: what the mod's Art tab and the cards show, without reading a dozen files. */
|
|
75
|
+
export function writeArtSummary(root, id) {
|
|
76
|
+
try {
|
|
77
|
+
const doc = readDecisions(root, id);
|
|
78
|
+
const manifest = readManifest(root, id);
|
|
79
|
+
const dir = join(root, '.studio', 'art', id);
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
const readJson = (f) => { try { return JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { return null; } };
|
|
82
|
+
const check = readJson('check.json');
|
|
83
|
+
const lineup = readJson('lineup.json');
|
|
84
|
+
const summary = {
|
|
85
|
+
v: 1, game: id, at: new Date().toISOString(), path: doc?.path ?? null, line: doc ? oneLine(doc) : null,
|
|
86
|
+
phases: doc ? PHASES.map((p) => ({ ...p, ...phaseProgress(doc)[p.id] })) : [],
|
|
87
|
+
decisions: decisionRows(doc).map((p) => ({ phase: p.id, label: p.label, rows: p.rows.map(({ id: d, name, label, state, by, colours }) => ({ id: d, name, label, state, by, colours })) })),
|
|
88
|
+
cast: manifest.assets.map((a) => ({ id: a.id, kind: a.kind, route: a.route, tier: a.tier ?? null, license: a.license?.kind ?? null, state: a.review?.state ?? 'auto', usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })),
|
|
89
|
+
stale: doc ? staleAssets(doc, manifest).map((s) => s.id) : [],
|
|
90
|
+
licence: licenceProblems(root, id, manifest),
|
|
91
|
+
spend: artSpend(root, id),
|
|
92
|
+
check: check ? { ok: check.ok, at: check.at, totals: check.totals, budgets: { drawCalls: check.budgets?.drawCalls, triangles: check.budgets?.triangles, textureMB: check.budgets?.textureMB, firstPlayMB: check.budgets?.firstPlayMB }, failing: check.rows.filter((r) => !r.ok).map((r) => r.id) } : null,
|
|
93
|
+
lineup: lineup ? { at: lineup.at, flagged: lineup.flagged, images: lineup.images } : null,
|
|
94
|
+
board: doc?.board ? { chosen: doc.board.chosen, directions: doc.board.directions.map((d) => ({ id: d.id, label: d.label, swatch: d.swatch, mood: d.mood?.path ?? null })) } : null,
|
|
95
|
+
};
|
|
96
|
+
writeFileSync(join(dir, 'latest.json'), `${JSON.stringify(summary, null, 2)}\n`);
|
|
97
|
+
return summary;
|
|
98
|
+
} catch { return null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const list = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
102
|
+
|
|
103
|
+
export async function styleCommand(root, sub, positional, flags, { log = () => {} } = {}) {
|
|
104
|
+
const known = ['init', 'show', 'set', 'steer', 'lock', 'unlock', 'board', 'pick', 'mood', 'golden', 'blast', 'prompt'];
|
|
105
|
+
const verb = known.includes(sub) ? sub : 'show';
|
|
106
|
+
const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
|
|
107
|
+
const id = gameOf(root, args[0], `style ${verb}`);
|
|
108
|
+
const manifest = () => readManifest(root, id);
|
|
109
|
+
let r;
|
|
110
|
+
if (verb === 'init') r = initDecisions(root, id, { prompt: String(flags.get('prompt') ?? ''), path: flags.has('hands-on') ? 'hands-on' : flags.has('automatic') ? 'automatic' : null, budget: flags.has('budget') ? Number(flags.get('budget')) : null });
|
|
111
|
+
else if (verb === 'show') {
|
|
112
|
+
const doc = readDecisions(root, id);
|
|
113
|
+
if (!doc) return { ok: false, command: 'style', why: `games/${id} has no decisions yet: homie-studio style init ${id} --prompt "<the person's words>"` };
|
|
114
|
+
const phase = flags.get('phase') ?? null;
|
|
115
|
+
r = { ok: true, command: 'style', id, line: oneLine(doc), path: doc.path, budget: doc.budget, phases: decisionRows(doc).filter((p) => !phase || p.id === phase), progress: phaseProgress(doc), prompt: doc.derived?.prompt ?? null, board: doc.board ? { chosen: doc.board.chosen, directions: doc.board.directions.map((d) => ({ id: d.id, label: d.label, swatch: d.swatch, mood: d.mood?.path ?? null })) } : null, golden: doc.golden ?? [], stale: staleAssets(doc, manifest()) };
|
|
116
|
+
} else if (verb === 'set') {
|
|
117
|
+
if (!args[1] || args[2] === undefined) return { ok: false, command: 'style set', why: `usage: homie-studio style set <id> <decision> <value> (decisions: ${CATALOGUE_IDS.slice(0, 10).join(', ')}, …)` };
|
|
118
|
+
r = setDecision(root, id, args[1], args.slice(2).join(' '), { by: flags.get('by') === 'person' ? 'person' : 'ai', why: flags.get('why') ?? null, words: flags.get('words') ?? null, unlock: flags.has('unlock'), reason: flags.get('reason') ?? null, confirm: flags.has('confirm'), manifest: manifest() });
|
|
119
|
+
} else if (verb === 'steer') r = steerDecision(root, id, args[1], args.slice(2).join(' ') || String(flags.get('words') ?? ''), { manifest: manifest() });
|
|
120
|
+
else if (verb === 'lock') r = lockDecision(root, id, args[1] ?? 'style', { by: 'person', words: flags.get('words') ?? null });
|
|
121
|
+
else if (verb === 'unlock') r = unlockDecision(root, id, args[1], { reason: flags.get('reason') ?? null, by: 'person' });
|
|
122
|
+
else if (verb === 'board') { const { styleBoard } = await import('./style-board.mjs'); r = await styleBoard(root, id, { prompt: String(flags.get('prompt') ?? ''), log, library: !flags.has('no-library') }); }
|
|
123
|
+
else if (verb === 'pick') {
|
|
124
|
+
const mixes = Object.fromEntries(list(flags.get('mix')).map((x) => x.split('=')).filter(([k, v]) => k && v));
|
|
125
|
+
r = pickDirection(root, id, args[1] && !args[1].includes('=') ? args[1] : null, { mixes, manifest: manifest() });
|
|
126
|
+
} else if (verb === 'mood') { const { recordMood } = await import('./style-board.mjs'); r = await recordMood(root, id, args[1], resolve(String(flags.get('image') ?? '')), { usd: flags.get('usd') ?? null, receipt: flags.get('receipt') ?? null, model: flags.get('model') ?? null }); }
|
|
127
|
+
else if (verb === 'golden') {
|
|
128
|
+
if (args[1] === 'add') { const { addGolden } = await import('./style-board.mjs'); r = await addGolden(root, id, resolve(String(args[2] ?? flags.get('image') ?? '')), { from: flags.get('from') ?? null }); }
|
|
129
|
+
else r = { ok: true, command: 'style golden', id, golden: readDecisions(root, id)?.golden ?? [] };
|
|
130
|
+
} else if (verb === 'blast') {
|
|
131
|
+
const doc = readDecisions(root, id);
|
|
132
|
+
if (!doc) return { ok: false, command: 'style blast', why: `games/${id} has no decisions yet` };
|
|
133
|
+
if (!doc.decisions[args[1]]) return { ok: false, command: 'style blast', why: `no decision "${args[1] ?? ''}"` };
|
|
134
|
+
r = { ok: true, command: 'style blast', id, blast: blastRadius(doc, args[1], manifest()) };
|
|
135
|
+
} else if (verb === 'prompt') {
|
|
136
|
+
const doc = readDecisions(root, id);
|
|
137
|
+
if (!doc) return { ok: false, command: 'style prompt', why: `games/${id} has no decisions yet` };
|
|
138
|
+
r = { ok: true, command: 'style prompt', id, prompt: derivedPrompt(doc), golden: doc.golden ?? [] };
|
|
139
|
+
}
|
|
140
|
+
if (r?.ok !== false) writeArtSummary(root, id);
|
|
141
|
+
return r;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function assetsCommand(root, sub, positional, flags, { log = () => {} } = {}) {
|
|
145
|
+
const known = ['list', 'find', 'add', 'redo', 'optimise', 'optimize', 'check', 'lineup', 'review', 'rights', 'stale', 'remove'];
|
|
146
|
+
const verb = known.includes(sub) ? (sub === 'optimize' ? 'optimise' : sub) : 'list';
|
|
147
|
+
const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
|
|
148
|
+
if (verb === 'find') {
|
|
149
|
+
const { loadIndex, searchLibrary, libraryBase } = await import('./library.mjs');
|
|
150
|
+
const { index, lib } = await loadIndex({ lib: libraryBase(flags.get('library') ?? undefined) });
|
|
151
|
+
const hits = searchLibrary(index, args.join(' '), { kind: flags.get('kind') ?? null, family: flags.get('family') ?? null, limit: Number(flags.get('limit') ?? 12) });
|
|
152
|
+
return { ok: true, command: 'assets find', query: args.join(' '), library: lib.base, version: index.version, total: index.items.length, items: hits.map(({ item, score, why }) => ({ id: item.id, name: item.name, kind: item.kind, family: item.family, pack: item.pack, tris: item.tris, kb: Math.round((item.files?.[0]?.bytes ?? 0) / 1024), heightM: item.heightM, suggestM: item.suggestM ?? null, rigged: Boolean(item.rigged), clips: item.clips ?? [], thumb: item.thumb, license: 'CC0 1.0', score: +score.toFixed(2), why })) };
|
|
153
|
+
}
|
|
154
|
+
if (verb === 'optimise') {
|
|
155
|
+
const { optimiseModel } = await import('./optimise.mjs');
|
|
156
|
+
if (!args[0] || !flags.get('out')) return { ok: false, command: 'assets optimise', why: 'usage: homie-studio assets optimise <model.glb> --out <file.glb> [--triangles 1500] [--texture 512] [--height <m>]' };
|
|
157
|
+
const r = await optimiseModel(resolve(args[0]), { out: resolve(String(flags.get('out'))), triangles: Number(flags.get('triangles') ?? 1500), texture: Number(flags.get('texture') ?? 512), height: flags.has('height') ? Number(flags.get('height')) : null, rigged: flags.has('rigged'), log });
|
|
158
|
+
const { glb, ...rest } = r;
|
|
159
|
+
return { ...rest, command: 'assets optimise' };
|
|
160
|
+
}
|
|
161
|
+
const id = gameOf(root, args[0], `assets ${verb}`);
|
|
162
|
+
let r;
|
|
163
|
+
if (verb === 'list') {
|
|
164
|
+
const m = readManifest(root, id);
|
|
165
|
+
const doc = readDecisions(root, id);
|
|
166
|
+
r = { ok: true, command: 'assets', id, assets: m.assets.map((a) => ({ id: a.id, kind: a.kind, route: a.route, tier: a.tier ?? null, card: a.card ?? null, license: a.license?.kind ?? null, remix: a.license?.remix ?? null, file: (a.files ?? []).find((f) => f.role === 'model')?.path ?? null, tris: a.measured?.tris ?? null, kb: a.measured?.glbKB ?? null, usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })), stale: doc ? staleAssets(doc, m).map((s) => s.id) : [], spend: artSpend(root, id) };
|
|
167
|
+
} else if (verb === 'add') {
|
|
168
|
+
if (flags.get('file')) {
|
|
169
|
+
const { importModel } = await import('./asset-import.mjs');
|
|
170
|
+
let steps = [];
|
|
171
|
+
if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
|
|
172
|
+
r = await importModel(root, id, String(flags.get('file')), { as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind') ?? 'prop', tier: flags.get('tier') ?? null, card: flags.get('card') ?? null, height: flags.get('height') ?? null, triangles: flags.get('triangles') ?? null, texture: flags.get('texture') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, notes: flags.get('notes') ?? null, owner: flags.get('owner') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, slug: flags.get('slug') ?? null, rigged: flags.has('rigged') });
|
|
173
|
+
} else {
|
|
174
|
+
if (!args[1]) return { ok: false, command: 'assets add', why: 'usage: homie-studio assets add <game> <library item> (assets find "<words>" lists them), or --file <model> --license <kind>' };
|
|
175
|
+
const { addFromLibrary, libraryBase } = await import('./library.mjs');
|
|
176
|
+
r = await addFromLibrary(root, id, args[1], { as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, lib: libraryBase(flags.get('library') ?? undefined) });
|
|
177
|
+
}
|
|
178
|
+
} else if (verb === 'redo') {
|
|
179
|
+
if (!args[1]) return { ok: false, command: 'assets redo', why: 'usage: homie-studio assets redo <game> <asset> (made again from its kept raw file, free)' };
|
|
180
|
+
const { redoModel } = await import('./asset-import.mjs');
|
|
181
|
+
r = await redoModel(root, id, args[1]);
|
|
182
|
+
} else if (verb === 'check') { const { assetsCheck } = await import('./asset-check.mjs'); r = await assetsCheck(root, id, { validate: !flags.has('no-validate') }); }
|
|
183
|
+
else if (verb === 'lineup') { const { assetsLineup } = await import('./style-board.mjs'); r = await assetsLineup(root, id, { log }); }
|
|
184
|
+
else if (verb === 'review') { const { recordReview } = await import('./style-board.mjs'); r = recordReview(root, id, { score: flags.get('score'), outliers: list(flags.get('outliers')), note: flags.get('note') ?? null }); }
|
|
185
|
+
else if (verb === 'rights') { const m = readManifest(root, id); const file = writeRights(root, id, m); syncCredits(root, id, m); r = { ok: true, command: 'assets rights', id, file, text: rightsMarkdown(root, id, m), licence: licenceProblems(root, id, m) }; }
|
|
186
|
+
else if (verb === 'stale') { const doc = readDecisions(root, id); r = { ok: true, command: 'assets stale', id, stale: doc ? staleAssets(doc, readManifest(root, id)) : [] }; }
|
|
187
|
+
else if (verb === 'remove') { const x = removeAsset(root, id, args[1]); r = { ok: x.removed, command: 'assets remove', id, asset: args[1], deleted: x.deleted, kept: x.kept, ...(x.removed ? {} : { why: `no asset "${args[1]}" in games/${id}/assets/manifest.json` }) }; }
|
|
188
|
+
if (r?.ok !== false || verb === 'check') writeArtSummary(root, id);
|
|
189
|
+
return r;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ------------------------------------------------------------------ what a person reads */
|
|
193
|
+
|
|
194
|
+
const MARK = { auto: '·', steered: '~', pinned: '●', locked: '■' };
|
|
195
|
+
|
|
196
|
+
export function artLines(r) {
|
|
197
|
+
const L = [];
|
|
198
|
+
switch (r.command) {
|
|
199
|
+
case 'style init':
|
|
200
|
+
L.push(`${r.summary}.`, ` ${r.picked.length} decision${r.picked.length === 1 ? '' : 's'} picked automatically (a ${r.genre} game)${r.kept.length ? `; ${r.kept.length} kept as the person set them` : ''}. ${r.file}`, ' Open the codex to change anything; steer, lock or the style board give the person control.');
|
|
201
|
+
break;
|
|
202
|
+
case 'style': {
|
|
203
|
+
L.push(`${r.id}: ${r.line}`, ` path: ${r.path}${r.budget ? `, art budget US$${r.budget.usd}` : ', free routes only (no art budget)'}`);
|
|
204
|
+
for (const p of r.phases) {
|
|
205
|
+
L.push(` ${p.label}:`);
|
|
206
|
+
for (const x of p.rows) L.push(` ${MARK[x.state] ?? ' '} ${x.id.padEnd(18)} ${x.label}${x.state !== 'auto' ? ` [${x.state}${x.by === 'person' ? ', the person' : x.by === 'use' ? ', by use' : ''}]` : ''}`);
|
|
207
|
+
}
|
|
208
|
+
L.push(' · auto ~ steered ● pinned by use ■ locked');
|
|
209
|
+
if (r.stale?.length) L.push(` stale: ${r.stale.map((s) => s.id).join(', ')}`);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'style set':
|
|
213
|
+
if (r.pending) { L.push(`Not changed yet: ${r.decision} is locked. If it changes:`, ...blastLines(r.blast), r.why); break; }
|
|
214
|
+
L.push(`${r.decision}: ${r.record.label} (${r.record.state}, rev ${r.record.rev})`, ...(r.blast ? blastLines(r.blast) : []), ...(r.stale?.length ? [` stale now: ${r.stale.join(', ')}`] : []));
|
|
215
|
+
break;
|
|
216
|
+
case 'style steer':
|
|
217
|
+
L.push(`${r.decision}: ${r.record.label} (steered: "${r.recorded}")`, ...(r.note ? [` ${r.note}`] : []), ...(r.stale?.length ? [` stale now: ${r.stale.join(', ')}`] : []));
|
|
218
|
+
break;
|
|
219
|
+
case 'style lock':
|
|
220
|
+
L.push(r.locked.length ? `Locked: ${r.locked.join(', ')}` : 'Nothing new to lock.', ...(r.already.length ? [` already locked: ${r.already.join(', ')}`] : []));
|
|
221
|
+
break;
|
|
222
|
+
case 'style unlock':
|
|
223
|
+
L.push(r.already ? `${r.decision} was not locked.` : `Unlocked ${r.decision} (now steered).`);
|
|
224
|
+
break;
|
|
225
|
+
case 'style board':
|
|
226
|
+
L.push(`The style board for ${r.title} (drawn on ${r.renderer ?? 'this computer'}, free):`);
|
|
227
|
+
for (const d of r.directions) L.push(` ${d.id.toUpperCase()}: ${d.label} [${d.family}]`, ` swatch: games/${r.id}/${d.swatch}${d.mood ? ` mood (target): games/${r.id}/${d.mood.path}` : ''}`);
|
|
228
|
+
L.push(' Pick, Mix, Steer or Lock: homie-studio style pick <id> <a|b|c> [--mix style.palette=b]; style lock <id> style --words "…"');
|
|
229
|
+
break;
|
|
230
|
+
case 'style pick':
|
|
231
|
+
L.push(r.changed.length ? `Picked ${r.chosen ? `direction ${r.chosen.toUpperCase()}` : 'a mix'}: ${r.changed.join(', ')}` : 'Nothing changed (the board\'s values are the game\'s already).', ...(r.refused.length ? [` left as locked: ${r.refused.join(', ')}`] : []), ...(r.stale?.length ? [` stale now: ${r.stale.join(', ')}`] : []));
|
|
232
|
+
break;
|
|
233
|
+
case 'style mood':
|
|
234
|
+
L.push(`Mood image (a target, not what the game draws) for direction ${r.direction.toUpperCase()}: ${r.mood.path}${r.mood.usd ? ` (US$${r.mood.usd})` : ''}`);
|
|
235
|
+
break;
|
|
236
|
+
case 'style golden':
|
|
237
|
+
L.push(`${r.golden.length} golden image${r.golden.length === 1 ? '' : 's'}:`, ...r.golden.map((g) => ` ${g.path} (from ${g.from})`));
|
|
238
|
+
break;
|
|
239
|
+
case 'style blast':
|
|
240
|
+
L.push(...blastLines(r.blast));
|
|
241
|
+
break;
|
|
242
|
+
case 'style prompt':
|
|
243
|
+
L.push(`Derived style prompt (rev ${r.prompt.rev}):`, ` ${r.prompt.text}`, ...(r.golden.length ? [`Golden images (references): ${r.golden.map((g) => g.path).join(', ')}`] : []));
|
|
244
|
+
break;
|
|
245
|
+
case 'assets':
|
|
246
|
+
L.push(`${r.id}: ${r.assets.length} asset${r.assets.length === 1 ? '' : 's'}${r.spend.used ? `, US$${r.spend.used} spent${r.spend.cap !== null ? ` of US$${r.spend.cap}` : ''}` : ', US$0 spent'}`);
|
|
247
|
+
for (const a of r.assets) L.push(` ${a.id.padEnd(20)} ${a.kind.padEnd(9)} ${a.route.padEnd(10)} ${String(a.license ?? 'NO LICENCE').padEnd(10)} ${a.tris ?? '?'} tris, ${a.kb ?? '?'} KB${a.usd ? `, US$${a.usd}` : ''}${r.stale.includes(a.id) ? ' STALE' : ''}`);
|
|
248
|
+
break;
|
|
249
|
+
case 'assets find':
|
|
250
|
+
L.push(`${r.items.length} of ${r.total} library items for "${r.query}" (CC0, free; ${r.library}):`);
|
|
251
|
+
for (const i of r.items) L.push(` ${i.id.padEnd(46)} ${i.kind.padEnd(9)} ${String(i.tris).padStart(5)} tris ${String(i.kb).padStart(4)} KB${i.rigged ? ` rigged${i.clips.length ? `, ${i.clips.length} clips` : ''}` : ''}`);
|
|
252
|
+
if (r.items.length) L.push(` Add one: homie-studio assets add <game> ${r.items[0].id} [--height <m>]`);
|
|
253
|
+
break;
|
|
254
|
+
case 'assets add':
|
|
255
|
+
L.push(`games/${r.game}: ${r.asset} added (${r.item ? `from the library: ${r.item}` : r.route}; licence ${r.license}).`, ...(r.after ? [` ${r.before.tris} -> ${r.after.tris} triangles, ${r.before.kb} -> ${r.after.kb} KB, ${r.after.heightM} m tall; raw kept in ${r.raw} (git-ignored)`] : []), ...(r.pinned?.length ? [` pinned by use: ${r.pinned.join(', ')}`] : []), ...(r.warnings ?? []).map((w) => ` note: ${w}`));
|
|
256
|
+
break;
|
|
257
|
+
case 'assets redo':
|
|
258
|
+
L.push(`games/${r.game}: ${r.asset} made again from its raw file (free): ${r.before.tris} -> ${r.after.tris} triangles, ${r.after.kb} KB, ${r.after.heightM} m (${r.ops.join(', ')})`, ...(r.warnings ?? []).map((w) => ` note: ${w}`));
|
|
259
|
+
break;
|
|
260
|
+
case 'assets optimise':
|
|
261
|
+
L.push(`${r.out}: ${r.before.triangles} -> ${r.after.triangles} triangles, ${Math.round(r.rawBytes / 1024)} -> ${Math.round(r.bytes / 1024)} KB (${r.ops.join(', ')})`, ...r.warnings.map((w) => ` note: ${w}`));
|
|
262
|
+
break;
|
|
263
|
+
case 'assets lineup':
|
|
264
|
+
if (r.ok === false) { L.push(r.why); break; }
|
|
265
|
+
L.push(`Lineup of ${r.rows.length} asset${r.rows.length === 1 ? '' : 's'} (${r.flagged} flagged), pictures: ${r.images.front}, ${r.images.quarter}, ${r.images.silhouettes}`);
|
|
266
|
+
for (const x of r.rows) L.push(` ${x.flags.length ? 'FLAG' : 'ok '} ${x.id}${x.size ? ` (${x.size[1]} m)` : ''}${x.drift ? (x.drift.repaint ? ', repainted from style.json in the game' : `, palette distance ${x.drift.mean}`) : ''}${x.flags.length ? `: ${x.flags.join('; ')}` : ''}`);
|
|
267
|
+
L.push(' Next: a fresh reviewer scores "one game?" from the board, golden images and this lineup (assets review <id> --score <n>).');
|
|
268
|
+
break;
|
|
269
|
+
case 'assets review':
|
|
270
|
+
L.push(`Review: ${r.review.score}/10, ${r.review.verdict}${r.review.outliers.length ? `; needs review: ${r.review.outliers.join(', ')}` : ''}`);
|
|
271
|
+
break;
|
|
272
|
+
case 'assets rights':
|
|
273
|
+
L.push(`Wrote ${r.file} (and credits.json).`, ...r.licence.map((l) => ` ${l.level === 'refuse' ? 'FIX ' : 'note'} ${l.asset}: ${l.problem}`));
|
|
274
|
+
break;
|
|
275
|
+
case 'assets stale':
|
|
276
|
+
L.push(r.stale.length ? `Stale (made under an older decision; nothing is remade by itself):` : 'Nothing is stale.', ...r.stale.map((s) => ` ${s.id}: ${s.decisions.map((d) => `${d.id} rev ${d.was} -> ${d.now}`).join(', ')}`));
|
|
277
|
+
break;
|
|
278
|
+
case 'assets remove':
|
|
279
|
+
L.push(r.ok ? `Removed ${r.asset} from the manifest${r.deleted.length ? `, and its shipped copy (${r.deleted.join(', ')})` : ''}.${r.kept.length ? ` Kept ${r.kept.join(', ')}: changed since it was recorded, so delete it yourself if it is unused.` : ''} A raw file in art/ stays.` : r.why);
|
|
280
|
+
break;
|
|
281
|
+
case 'assets check':
|
|
282
|
+
L.push(...checkLines(r));
|
|
283
|
+
break;
|
|
284
|
+
default:
|
|
285
|
+
L.push(JSON.stringify(r, null, 2));
|
|
286
|
+
}
|
|
287
|
+
return L;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function blastLines(b) {
|
|
291
|
+
const L = [`Changing the ${b.name} (${b.from ?? '?'}${b.to ? ` -> ${b.to}` : ''}): ${b.assets.length} asset${b.assets.length === 1 ? '' : 's'} would go stale${b.assets.length ? `; remaking the ones with no free fix costs about US$${b.totals.usd.toFixed(2)}` : ''}.`];
|
|
292
|
+
for (const a of b.assets) L.push(` ${a.id} (${a.route}${a.card ? `, ${a.card}` : ''}): ${a.free ? `free: ${a.free}` : a.remake.usd ? `US$${a.remake.usd.toFixed(2)} (${a.remake.how})` : a.remake.how}`);
|
|
293
|
+
if (b.decisions.length) L.push(` also worth a look: ${b.decisions.slice(0, 8).join(', ')}`);
|
|
294
|
+
L.push(` ${b.note}`);
|
|
295
|
+
return L;
|
|
296
|
+
}
|