@homie-rocks/studio 0.21.0 → 0.23.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 +68 -1
- package/README.md +92 -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 +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- 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 +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -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 +39 -2
- 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 +76 -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/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- 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 +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/lib/optimise.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MAKING A MODEL PHONE-SIZED, AND MEASURING IT (free, on this computer).
|
|
3
|
+
*
|
|
4
|
+
* optimiseModel(input, { out, triangles, texture, height }) a raw .glb (a provider's, a pack's, the person's own)
|
|
5
|
+
* becomes one shipped .glb: flat colours into one palette picture, one draw call where it can, the pivot at the
|
|
6
|
+
* bottom centre, scaled to its size in metres, welded and simplified to its triangle budget (meshoptimizer),
|
|
7
|
+
* pictures resized and re-encoded as WebP (sharp), meshopt geometry, nothing unused. Raw files are never
|
|
8
|
+
* changed; the result says what each step did, before and after.
|
|
9
|
+
* inspectModel(input) triangles, vertices, draw calls, materials, pictures and their GPU memory,
|
|
10
|
+
* bones and clips, the bounding box in metres, the Khronos glTF-Validator's errors, and the file's own safety
|
|
11
|
+
* check (assets/safety.mjs).
|
|
12
|
+
* albedoOf(input) the model's dominant surface colours (k-means over its base-colour
|
|
13
|
+
* pictures and flat colours, weighted by how much of it they cover): the palette-drift check reads them.
|
|
14
|
+
*
|
|
15
|
+
* The tools are @gltf-transform (MIT), meshoptimizer (MIT), the Khronos glTF-Validator (Apache-2.0) and sharp
|
|
16
|
+
* (Apache-2.0), all dependencies of @homie-rocks/studio, loaded only when a model command runs. Draco and KTX2 inputs
|
|
17
|
+
* are refused here (ask for a plain .glb); KTX2 output needs KTX-Software's toktx and is not offered in phase 1: WebP
|
|
18
|
+
* is the house default.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { dirname, resolve } from 'node:path';
|
|
23
|
+
import { LIMITS, checkGlb, readGlb } from '../assets/safety.mjs';
|
|
24
|
+
import { dominant } from './colour.mjs';
|
|
25
|
+
|
|
26
|
+
let tools = null;
|
|
27
|
+
|
|
28
|
+
/** The glTF toolchain, loaded once: { core, ext, fn, mo, sharp, io, validator }. */
|
|
29
|
+
export async function modelTools() {
|
|
30
|
+
if (tools) return tools;
|
|
31
|
+
let core; let ext; let fn; let mo;
|
|
32
|
+
try {
|
|
33
|
+
[core, ext, fn, mo] = await Promise.all([import('@gltf-transform/core'), import('@gltf-transform/extensions'), import('@gltf-transform/functions'), import('meshoptimizer')]);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`the model tools are not installed here (${error.message.split('\n')[0]}): run npm install in the studio, then this again`);
|
|
36
|
+
}
|
|
37
|
+
await Promise.all([mo.MeshoptEncoder.ready, mo.MeshoptDecoder.ready, mo.MeshoptSimplifier.ready]);
|
|
38
|
+
let sharp = null;
|
|
39
|
+
try { sharp = (await import('sharp')).default; } catch { sharp = null; }
|
|
40
|
+
let validator = null;
|
|
41
|
+
try { const v = await import('gltf-validator'); validator = v.default ?? v; } catch { validator = null; }
|
|
42
|
+
// Quiet: the steps report what they did in the result, never on the console.
|
|
43
|
+
const logger = new core.Logger(core.Logger.Verbosity.ERROR);
|
|
44
|
+
const io = new core.NodeIO().setLogger(logger).registerExtensions(ext.ALL_EXTENSIONS).registerDependencies({ 'meshopt.decoder': mo.MeshoptDecoder, 'meshopt.encoder': mo.MeshoptEncoder });
|
|
45
|
+
tools = { core, ext, fn, mo, sharp, io, validator, logger };
|
|
46
|
+
return tools;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const bytesOf = (input) => (input instanceof Uint8Array ? input : readFileSync(input));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A model on disk whose pictures or buffers sit beside it (a pack's .glb naming "Textures/colormap.png", a .gltf
|
|
53
|
+
* with its .bin): its relative files are read from its own folder only (never "..", never an address, each within
|
|
54
|
+
* the import cap), and the result is one self-contained GLB. Anything else is refused as an external URI.
|
|
55
|
+
*/
|
|
56
|
+
async function readLocal(path, limits) {
|
|
57
|
+
const { io } = await modelTools();
|
|
58
|
+
const raw = readFileSync(path);
|
|
59
|
+
let json;
|
|
60
|
+
if (raw.subarray(0, 4).toString('latin1') === 'glTF') json = readGlb(raw, { maxJson: limits.json }).json;
|
|
61
|
+
else { try { json = JSON.parse(raw.toString('utf8')); } catch { throw new Error('not a glTF file (.glb or .gltf)'); } }
|
|
62
|
+
const dir = dirname(resolve(path));
|
|
63
|
+
const uris = [...(json.buffers ?? []), ...(json.images ?? [])].map((x) => x?.uri).filter((u) => typeof u === 'string' && !/^data:/i.test(u));
|
|
64
|
+
let total = raw.byteLength;
|
|
65
|
+
for (const u of uris) {
|
|
66
|
+
const rel = decodeURIComponent(u);
|
|
67
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rel) || rel.startsWith('/') || rel.split(/[\\/]/).includes('..')) throw new Error(`refused: it loads ${u.slice(0, 60)} from an address or outside its folder (an external URI)`);
|
|
68
|
+
const abs = resolve(dir, rel);
|
|
69
|
+
if (!existsSync(abs)) throw new Error(`it names ${rel}, which is not beside it`);
|
|
70
|
+
total += statSync(abs).size;
|
|
71
|
+
if (total > limits.bytes) throw new Error(`it and its files are over ${Math.round(limits.bytes / 1024 / 1024)} MB`);
|
|
72
|
+
}
|
|
73
|
+
const doc = await io.read(path);
|
|
74
|
+
const { fn, logger } = await modelTools();
|
|
75
|
+
doc.setLogger(logger);
|
|
76
|
+
await doc.transform(fn.unpartition());
|
|
77
|
+
const bytes = await io.writeBinary(doc);
|
|
78
|
+
const safe = checkGlb(bytes, limits);
|
|
79
|
+
if (!safe.ok) throw new Error(`refused: ${safe.problems.slice(0, 4).join('; ')}`);
|
|
80
|
+
return { doc, safe, bytes };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bytes of a model from a path (resolving its own sibling files) or from bytes: a self-contained GLB. */
|
|
84
|
+
export async function selfContained(input, limits = LIMITS.import) {
|
|
85
|
+
if (input instanceof Uint8Array) return input;
|
|
86
|
+
const bytes = readFileSync(input);
|
|
87
|
+
if (checkGlb(bytes, limits).ok) return bytes;
|
|
88
|
+
return (await readLocal(input, limits)).bytes;
|
|
89
|
+
}
|
|
90
|
+
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
91
|
+
const round = (n, d = 3) => +Number(n).toFixed(d);
|
|
92
|
+
|
|
93
|
+
/** A Document from bytes, refusing what this pipeline cannot read (Draco, KTX2) in plain words. */
|
|
94
|
+
async function readDoc(bytes, limits = LIMITS.import) {
|
|
95
|
+
const safe = checkGlb(bytes, limits);
|
|
96
|
+
if (!safe.ok) throw new Error(`refused: ${safe.problems.slice(0, 4).join('; ')}`);
|
|
97
|
+
const req = safe.info.extensions.required;
|
|
98
|
+
if (req.includes('KHR_draco_mesh_compression')) throw new Error('Draco-compressed geometry: export it again as a plain .glb (meshopt is applied here)');
|
|
99
|
+
if (req.includes('KHR_texture_basisu')) throw new Error('KTX2 pictures: export it again with PNG, JPEG or WebP pictures');
|
|
100
|
+
const { io } = await modelTools();
|
|
101
|
+
const doc = await io.readBinary(bytes);
|
|
102
|
+
doc.setLogger(tools.logger);
|
|
103
|
+
return { doc, safe };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Counts and sizes of a Document (what `assets check` holds against the budgets). */
|
|
107
|
+
export async function measureDoc(doc) {
|
|
108
|
+
const { fn } = await modelTools();
|
|
109
|
+
const root = doc.getRoot();
|
|
110
|
+
let triangles = 0; let vertices = 0; let drawCalls = 0;
|
|
111
|
+
const scene = root.getDefaultScene() ?? root.listScenes()[0];
|
|
112
|
+
const seenMaterials = new Set();
|
|
113
|
+
if (scene) {
|
|
114
|
+
scene.traverse((node) => {
|
|
115
|
+
const mesh = node.getMesh();
|
|
116
|
+
if (!mesh) return;
|
|
117
|
+
const inst = node.getExtension('EXT_mesh_gpu_instancing');
|
|
118
|
+
const n = inst ? (inst.listAttributes()[0]?.getCount() ?? 1) : 1;
|
|
119
|
+
for (const prim of mesh.listPrimitives()) {
|
|
120
|
+
const idx = prim.getIndices();
|
|
121
|
+
const pos = prim.getAttribute('POSITION');
|
|
122
|
+
const c = idx ? idx.getCount() : (pos?.getCount() ?? 0);
|
|
123
|
+
const mode = prim.getMode();
|
|
124
|
+
triangles += (mode === 4 ? Math.floor(c / 3) : mode === 5 || mode === 6 ? Math.max(0, c - 2) : 0) * n;
|
|
125
|
+
vertices += (pos?.getCount() ?? 0) * n;
|
|
126
|
+
drawCalls += inst ? 1 : n;
|
|
127
|
+
if (prim.getMaterial()) seenMaterials.add(prim.getMaterial());
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const textures = root.listTextures().map((t) => {
|
|
132
|
+
const px = t.getSize();
|
|
133
|
+
const slots = fn.listTextureSlots(t);
|
|
134
|
+
return { name: t.getName() || t.getURI() || '', mimeType: t.getMimeType(), px: px ? [px[0], px[1]] : null, bytes: t.getImage()?.byteLength ?? 0, gpuBytes: px ? Math.round(px[0] * px[1] * 4 * (4 / 3)) : 0, slots };
|
|
135
|
+
});
|
|
136
|
+
let box = null;
|
|
137
|
+
if (scene) {
|
|
138
|
+
const b = fn.getBounds(scene);
|
|
139
|
+
if (Number.isFinite(b.min[0]) && Number.isFinite(b.max[0])) box = { min: b.min.map((v) => round(v)), max: b.max.map((v) => round(v)), size: b.max.map((v, i) => round(v - b.min[i])) };
|
|
140
|
+
}
|
|
141
|
+
const skins = root.listSkins();
|
|
142
|
+
return {
|
|
143
|
+
triangles, vertices, drawCalls,
|
|
144
|
+
materials: seenMaterials.size,
|
|
145
|
+
textures,
|
|
146
|
+
textureGpuBytes: textures.reduce((s, t) => s + t.gpuBytes, 0),
|
|
147
|
+
maxTexturePx: textures.reduce((m, t) => Math.max(m, ...(t.px ?? [0])), 0),
|
|
148
|
+
bones: skins.reduce((m, s) => Math.max(m, s.listJoints().length), 0),
|
|
149
|
+
skins: skins.length,
|
|
150
|
+
clips: root.listAnimations().map((a) => a.getName() || 'clip'),
|
|
151
|
+
box,
|
|
152
|
+
heightM: box ? box.size[1] : null,
|
|
153
|
+
// Where the pivot is: the bottom centre is (0, min.y = 0, 0) with the footprint around x = z = 0.
|
|
154
|
+
pivot: box ? { bottom: Math.abs(box.min[1]) < 0.01 * Math.max(0.01, box.size[1]), centred: Math.abs((box.min[0] + box.max[0]) / 2) < 0.05 * Math.max(0.01, box.size[0]) && Math.abs((box.min[2] + box.max[2]) / 2) < 0.05 * Math.max(0.01, box.size[2]) } : null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The Khronos glTF-Validator's verdict: { errors, warnings, messages: [first few] } (null when it is not installed). */
|
|
159
|
+
export async function validateModel(bytes) {
|
|
160
|
+
const { validator } = await modelTools();
|
|
161
|
+
if (!validator?.validateBytes) return null;
|
|
162
|
+
try {
|
|
163
|
+
const report = await validator.validateBytes(new Uint8Array(bytes), { maxIssues: 50, externalResourceFunction: (uri) => Promise.reject(new Error(`external resource ${uri} refused`)) });
|
|
164
|
+
const msgs = (report.issues?.messages ?? []).filter((m) => m.severity <= 1).slice(0, 8).map((m) => `${m.severity === 0 ? 'error' : 'warning'} ${m.code}${m.pointer ? ` at ${m.pointer}` : ''}: ${m.message}`);
|
|
165
|
+
return { errors: report.issues?.numErrors ?? 0, warnings: report.issues?.numWarnings ?? 0, messages: msgs };
|
|
166
|
+
} catch (error) { return { errors: 1, warnings: 0, messages: [`the validator could not read it: ${error.message}`] }; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** inspectModel(path | bytes): the safety check, the validator and the measurements of one .glb. */
|
|
170
|
+
export async function inspectModel(input, { limits = LIMITS.game, resolveLocal = false } = {}) {
|
|
171
|
+
const bytes = resolveLocal ? await selfContained(input, LIMITS.import) : bytesOf(input);
|
|
172
|
+
const safe = checkGlb(bytes, limits);
|
|
173
|
+
const base = { bytes: bytes.byteLength, sha256: sha256(bytes), safe };
|
|
174
|
+
if (!safe.ok) return { ...base, ok: false, measured: null, validator: null };
|
|
175
|
+
let doc;
|
|
176
|
+
try { ({ doc } = await readDoc(bytes, LIMITS.import)); } catch (error) { return { ...base, ok: false, measured: null, validator: null, why: error.message }; }
|
|
177
|
+
const measured = await measureDoc(doc);
|
|
178
|
+
const validator = await validateModel(bytes);
|
|
179
|
+
return { ...base, ok: !validator || validator.errors === 0, measured, validator };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The dominant colours of a model's surface: [{ hex, share }]. Each triangle (up to 4,000, spread evenly) counts by its
|
|
184
|
+
* area in metres: its colour is its material's base colour times the base-colour picture at the triangle's middle
|
|
185
|
+
* (its UVs), so unused corners of a texture atlas never count.
|
|
186
|
+
*/
|
|
187
|
+
export async function albedoOf(input, { k = 5 } = {}) {
|
|
188
|
+
const bytes = await selfContained(input, LIMITS.import);
|
|
189
|
+
const { doc } = await readDoc(bytes, LIMITS.import);
|
|
190
|
+
const { sharp } = await modelTools();
|
|
191
|
+
const pictures = new Map();
|
|
192
|
+
const pictureOf = async (tex) => {
|
|
193
|
+
if (!tex || !sharp) return null;
|
|
194
|
+
if (pictures.has(tex)) return pictures.get(tex);
|
|
195
|
+
let pic = null;
|
|
196
|
+
try { const r = await sharp(Buffer.from(tex.getImage())).resize(256, 256, { fit: 'fill' }).removeAlpha().raw().toBuffer({ resolveWithObject: true }); pic = { w: r.info.width, h: r.info.height, data: r.data }; } catch { pic = null; }
|
|
197
|
+
pictures.set(tex, pic);
|
|
198
|
+
return pic;
|
|
199
|
+
};
|
|
200
|
+
const samples = [];
|
|
201
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0];
|
|
202
|
+
const prims = [];
|
|
203
|
+
scene?.traverse((node) => { const mesh = node.getMesh(); if (mesh) for (const p of mesh.listPrimitives()) if (p.getMode() === 4) prims.push({ p, m: node.getWorldMatrix() }); });
|
|
204
|
+
const totalTris = prims.reduce((n, { p }) => n + Math.floor((p.getIndices()?.getCount() ?? p.getAttribute('POSITION')?.getCount() ?? 0) / 3), 0) || 1;
|
|
205
|
+
const step = Math.max(1, Math.floor(totalTris / 4000));
|
|
206
|
+
for (const { p, m } of prims) {
|
|
207
|
+
const mat = p.getMaterial();
|
|
208
|
+
const f = mat ? mat.getBaseColorFactor() : [1, 1, 1, 1];
|
|
209
|
+
const pic = await pictureOf(mat?.getBaseColorTexture());
|
|
210
|
+
const pos = p.getAttribute('POSITION'); const uv = p.getAttribute('TEXCOORD_0'); const idx = p.getIndices();
|
|
211
|
+
if (!pos) continue;
|
|
212
|
+
const n = Math.floor((idx ? idx.getCount() : pos.getCount()) / 3);
|
|
213
|
+
const at = (i) => (idx ? idx.getScalar(i) : i);
|
|
214
|
+
const a = [0, 0, 0]; const b = [0, 0, 0]; const c = [0, 0, 0];
|
|
215
|
+
for (let t = 0; t < n; t += step) {
|
|
216
|
+
const i0 = at(t * 3); const i1 = at(t * 3 + 1); const i2 = at(t * 3 + 2);
|
|
217
|
+
pos.getElement(i0, a); pos.getElement(i1, b); pos.getElement(i2, c);
|
|
218
|
+
const ux = b[0] - a[0]; const uy = b[1] - a[1]; const uz = b[2] - a[2]; const vx = c[0] - a[0]; const vy = c[1] - a[1]; const vz = c[2] - a[2];
|
|
219
|
+
const scale = Math.cbrt(Math.abs(m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8]) + m[2] * (m[4] * m[9] - m[5] * m[8]))) || 1;
|
|
220
|
+
const area = 0.5 * Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) * scale * scale;
|
|
221
|
+
if (!(area > 0)) continue;
|
|
222
|
+
let rgb = [f[0], f[1], f[2]].map((v) => linearToSrgb255(v));
|
|
223
|
+
if (pic && uv) {
|
|
224
|
+
const t0 = uv.getElement(i0, []); const t1 = uv.getElement(i1, []); const t2 = uv.getElement(i2, []);
|
|
225
|
+
const u = (t0[0] + t1[0] + t2[0]) / 3; const v = (t0[1] + t1[1] + t2[1]) / 3;
|
|
226
|
+
const x = Math.min(pic.w - 1, Math.max(0, Math.floor((u - Math.floor(u)) * pic.w))); const y = Math.min(pic.h - 1, Math.max(0, Math.floor((v - Math.floor(v)) * pic.h)));
|
|
227
|
+
const o = (y * pic.w + x) * 3;
|
|
228
|
+
rgb = [pic.data[o], pic.data[o + 1], pic.data[o + 2]].map((tx, ch) => linearToSrgb255(srgbToLinear01(tx) * f[ch]));
|
|
229
|
+
}
|
|
230
|
+
samples.push({ rgb, w: area * step });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return dominant(samples, k);
|
|
234
|
+
}
|
|
235
|
+
const srgbToLinear01 = (v) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
236
|
+
const linearToSrgb255 = (v) => { const c = Math.max(0, Math.min(1, v)); return Math.round(255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)); };
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One raw model into one shipped model. Options:
|
|
240
|
+
* out where to write the .glb (else only the bytes come back)
|
|
241
|
+
* triangles the triangle budget (a prop 1,500; a signature prop 5,000; a hero 8,000); 0: keep them
|
|
242
|
+
* texture the largest picture side in pixels (512 for a prop, 1,024 for a hero)
|
|
243
|
+
* height the asset's height in metres (it is scaled to it); null keeps its size
|
|
244
|
+
* quality WebP quality for colour pictures (normal maps get more)
|
|
245
|
+
* rigged keep the node tree and every primitive apart (skins and clips), even when it has none
|
|
246
|
+
* metal keep metalness (a `pbr` game that lights with a reflection map); otherwise every material is drawn
|
|
247
|
+
* non-metal, because metal with nothing to reflect is black on a phone (a provider's textured prop
|
|
248
|
+
* usually says metallic 1 and leaves the rest to its texture)
|
|
249
|
+
* Returns { ok, glb (the bytes), bytes (their count), sha256, out, before, after, ops, warnings }.
|
|
250
|
+
*/
|
|
251
|
+
export async function optimiseModel(input, { out = null, triangles = 1500, texture = 512, height = null, quality = 82, rigged = false, metal = false, log = () => {} } = {}) {
|
|
252
|
+
const t = await modelTools();
|
|
253
|
+
const { fn, mo, sharp, io } = t;
|
|
254
|
+
const raw = await selfContained(input, LIMITS.import);
|
|
255
|
+
const { doc } = await readDoc(raw, LIMITS.import);
|
|
256
|
+
const before = await measureDoc(doc);
|
|
257
|
+
const ops = [];
|
|
258
|
+
const warnings = [];
|
|
259
|
+
const hasRig = rigged || before.skins > 0 || before.clips.length > 0;
|
|
260
|
+
|
|
261
|
+
await doc.transform(fn.dedup(), fn.prune());
|
|
262
|
+
// Metal draws black without a reflection map, and a phone game has none: a flat colour exported as fully rough metal
|
|
263
|
+
// (common in packs) is always plastic; with `metal` false (every style but pbr) so is everything else.
|
|
264
|
+
let unmetal = 0;
|
|
265
|
+
for (const m of doc.getRoot().listMaterials()) {
|
|
266
|
+
const roughFlat = m.getMetallicFactor() >= 0.9 && m.getRoughnessFactor() >= 0.9 && !m.getMetallicRoughnessTexture();
|
|
267
|
+
if (m.getMetallicFactor() > 0 && (roughFlat || !metal)) { m.setMetallicFactor(0); unmetal += 1; }
|
|
268
|
+
}
|
|
269
|
+
if (unmetal) ops.push(`metal -> 0 on ${unmetal} material${unmetal === 1 ? '' : 's'} (nothing to reflect on a phone)`);
|
|
270
|
+
// Flat colours into one small palette picture, then one draw call per material where nothing moves on its own.
|
|
271
|
+
if (!hasRig) {
|
|
272
|
+
const flats = doc.getRoot().listMaterials().filter((m) => !m.getBaseColorTexture()).length;
|
|
273
|
+
if (flats >= 2) { await doc.transform(fn.palette({ min: 2 })); ops.push(`palette (${flats} flat colours into one picture)`); }
|
|
274
|
+
await doc.transform(fn.flatten(), fn.join());
|
|
275
|
+
ops.push('flatten, join');
|
|
276
|
+
}
|
|
277
|
+
await doc.transform(fn.weld());
|
|
278
|
+
ops.push('weld');
|
|
279
|
+
|
|
280
|
+
// Scale to the card's height, then the pivot to the bottom centre (+Y up).
|
|
281
|
+
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0];
|
|
282
|
+
if (height && before.heightM) {
|
|
283
|
+
const s = height / before.heightM;
|
|
284
|
+
if (Math.abs(s - 1) > 0.001) {
|
|
285
|
+
const wrap = doc.createNode('homie-scale').setScale([s, s, s]);
|
|
286
|
+
for (const child of scene.listChildren()) { scene.removeChild(child); wrap.addChild(child); }
|
|
287
|
+
scene.addChild(wrap);
|
|
288
|
+
if (!hasRig) await doc.transform(fn.flatten());
|
|
289
|
+
ops.push(`scale ${round(s, 4)} (to ${height} m)`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
await doc.transform(fn.center({ pivot: 'below' }));
|
|
293
|
+
ops.push('center bottom');
|
|
294
|
+
|
|
295
|
+
// Simplify to the triangle budget, loosening the error until it fits (a closed silhouette first).
|
|
296
|
+
let now = (await measureDoc(doc)).triangles;
|
|
297
|
+
if (triangles > 0 && now > triangles) {
|
|
298
|
+
for (const error of [0.002, 0.006, 0.015, 0.03, 0.06, 0.12]) {
|
|
299
|
+
now = (await measureDoc(doc)).triangles;
|
|
300
|
+
if (now <= triangles) break;
|
|
301
|
+
const ratio = Math.max(0.005, Math.min(1, (triangles / now) * 0.97));
|
|
302
|
+
await doc.transform(fn.simplify({ simplifier: mo.MeshoptSimplifier, ratio, error }));
|
|
303
|
+
}
|
|
304
|
+
now = (await measureDoc(doc)).triangles;
|
|
305
|
+
ops.push(`simplify ${round(now / Math.max(1, before.triangles), 3)} (${before.triangles} to ${now} triangles)`);
|
|
306
|
+
if (now > triangles) warnings.push(`still ${now} triangles after simplifying (its budget is ${triangles}): the shape resists; a lower-poly source, or a looser budget with a reason`);
|
|
307
|
+
}
|
|
308
|
+
if (hasRig) { await doc.transform(fn.resample()); ops.push('resample clips'); }
|
|
309
|
+
|
|
310
|
+
// Pictures: no side over `texture`, WebP; normal maps at a higher quality.
|
|
311
|
+
const textured = doc.getRoot().listTextures().length;
|
|
312
|
+
if (textured) {
|
|
313
|
+
if (sharp) {
|
|
314
|
+
// A palette picture (flat colours, a few pixels a colour) is lossless: lossy WebP bleeds one colour into the next.
|
|
315
|
+
await doc.transform(fn.textureCompress({ encoder: sharp, targetFormat: 'webp', lossless: true, pattern: /^Palette/ }));
|
|
316
|
+
await doc.transform(fn.textureCompress({ encoder: sharp, targetFormat: 'webp', resize: [texture, texture], quality, pattern: /^(?!Palette)/, slots: /^(?!normalTexture).*$/ }));
|
|
317
|
+
await doc.transform(fn.textureCompress({ encoder: sharp, targetFormat: 'webp', resize: [texture, texture], quality: Math.min(95, quality + 10), pattern: /^(?!Palette)/, slots: /^normalTexture$/ }));
|
|
318
|
+
ops.push(`resize ${texture}`, 'webp');
|
|
319
|
+
} else warnings.push('sharp is not installed: the pictures were left as they were (npm install in the studio)');
|
|
320
|
+
}
|
|
321
|
+
if (hasRig) {
|
|
322
|
+
// A skinned model keeps float positions: quantizing would move its scale into the inverse bind matrices, and every
|
|
323
|
+
// measurement of the shipped file (its height, its box) would then be wrong. Meshopt's filters still compress it.
|
|
324
|
+
await doc.transform(fn.prune(), fn.unpartition(), fn.reorder({ encoder: mo.MeshoptEncoder }));
|
|
325
|
+
doc.createExtension(t.ext.EXTMeshoptCompression).setRequired(true).setEncoderOptions({ method: t.ext.EXTMeshoptCompression.EncoderMethod.FILTER });
|
|
326
|
+
// The filters store normals and rotations as normalized integers, which glTF allows only with this extension.
|
|
327
|
+
doc.createExtension(t.ext.KHRMeshQuantization).setRequired(true);
|
|
328
|
+
} else await doc.transform(fn.prune(), fn.unpartition(), fn.meshopt({ encoder: mo.MeshoptEncoder, level: 'medium' }));
|
|
329
|
+
ops.push(hasRig ? 'meshopt (filters, float positions)' : 'meshopt', 'prune');
|
|
330
|
+
|
|
331
|
+
const after = await measureDoc(doc);
|
|
332
|
+
const bytes = await io.writeBinary(doc);
|
|
333
|
+
const safe = checkGlb(bytes, LIMITS.game);
|
|
334
|
+
if (!safe.ok) warnings.push(...safe.problems.map((p) => `the result: ${p}`));
|
|
335
|
+
if (out) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, bytes); }
|
|
336
|
+
log(`optimised: ${before.triangles} -> ${after.triangles} triangles, ${Math.round(raw.byteLength / 1024)} -> ${Math.round(bytes.byteLength / 1024)} KB`);
|
|
337
|
+
return { ok: safe.ok, out, glb: bytes, bytes: bytes.byteLength, sha256: sha256(bytes), rawBytes: raw.byteLength, rawSha256: sha256(raw), before, after, ops, warnings, tool: `@gltf-transform/functions ${await toolVersion()}` };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let version = null;
|
|
341
|
+
async function toolVersion() {
|
|
342
|
+
if (version) return version;
|
|
343
|
+
// The package's exports map hides its package.json: resolve its entry, then read the package.json above it.
|
|
344
|
+
try {
|
|
345
|
+
const { createRequire } = await import('node:module');
|
|
346
|
+
const entry = createRequire(import.meta.url).resolve('@gltf-transform/functions');
|
|
347
|
+
const m = /^(.*[\\/]@gltf-transform[\\/]functions)[\\/]/.exec(entry);
|
|
348
|
+
version = JSON.parse(readFileSync(`${m[1]}/package.json`, 'utf8')).version;
|
|
349
|
+
} catch { version = 'unknown'; }
|
|
350
|
+
return version;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A grey box .glb of `size` metres (x, y, z), its pivot at the bottom centre: what a remix gets in place of an asset
|
|
355
|
+
* it may not carry, and what a refused model is drawn as.
|
|
356
|
+
*/
|
|
357
|
+
export async function placeholderGlb(size = [0.5, 0.5, 0.5], { name = 'placeholder', colour = [0.54, 0.56, 0.6, 1] } = {}) {
|
|
358
|
+
const { core, io } = await modelTools();
|
|
359
|
+
const doc = new core.Document();
|
|
360
|
+
const buffer = doc.createBuffer();
|
|
361
|
+
const [x, y, z] = size.map((v) => Math.max(0.01, Number(v) || 0.5));
|
|
362
|
+
const X = x / 2; const Z = z / 2;
|
|
363
|
+
// 24 vertices (flat normals per face), 12 triangles.
|
|
364
|
+
const faces = [
|
|
365
|
+
[[1, 0, 0], [[X, 0, -Z], [X, y, -Z], [X, y, Z], [X, 0, Z]]], [[-1, 0, 0], [[-X, 0, Z], [-X, y, Z], [-X, y, -Z], [-X, 0, -Z]]],
|
|
366
|
+
[[0, 1, 0], [[-X, y, -Z], [-X, y, Z], [X, y, Z], [X, y, -Z]]], [[0, -1, 0], [[-X, 0, Z], [-X, 0, -Z], [X, 0, -Z], [X, 0, Z]]],
|
|
367
|
+
[[0, 0, 1], [[-X, 0, Z], [X, 0, Z], [X, y, Z], [-X, y, Z]]], [[0, 0, -1], [[X, 0, -Z], [-X, 0, -Z], [-X, y, -Z], [X, y, -Z]]],
|
|
368
|
+
];
|
|
369
|
+
const pos = []; const nor = []; const idx = [];
|
|
370
|
+
faces.forEach(([n, quad], f) => { for (const p of quad) { pos.push(...p); nor.push(...n); } const b = f * 4; idx.push(b, b + 1, b + 2, b, b + 2, b + 3); });
|
|
371
|
+
const prim = doc.createPrimitive()
|
|
372
|
+
.setAttribute('POSITION', doc.createAccessor().setType('VEC3').setArray(new Float32Array(pos)).setBuffer(buffer))
|
|
373
|
+
.setAttribute('NORMAL', doc.createAccessor().setType('VEC3').setArray(new Float32Array(nor)).setBuffer(buffer))
|
|
374
|
+
.setIndices(doc.createAccessor().setType('SCALAR').setArray(new Uint16Array(idx)).setBuffer(buffer))
|
|
375
|
+
.setMaterial(doc.createMaterial(name).setBaseColorFactor(colour).setRoughnessFactor(0.9).setMetallicFactor(0));
|
|
376
|
+
const mesh = doc.createMesh(name).addPrimitive(prim);
|
|
377
|
+
doc.createScene(name).addChild(doc.createNode(name).setMesh(mesh));
|
|
378
|
+
return io.writeBinary(doc);
|
|
379
|
+
}
|
|
380
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A REMIX ARRIVES WITH ITS MODELS. The remix source (source.json) carries text only: the game's code, its
|
|
3
|
+
* assets/manifest.json and RIGHTS.md. The original site also serves /games/<id>/assets.json (lib/asset-manifest.mjs
|
|
4
|
+
* servedAssets): each asset's licence, what a remix gets, and for redistributable files their address and SHA-256.
|
|
5
|
+
*
|
|
6
|
+
* include fetched from the original studio's site, same origin as the source, checked by SHA-256 (and, for a
|
|
7
|
+
* model, by the safety rules), written where the original had it
|
|
8
|
+
* reference not carried: a grey placeholder of the recorded size, and the origin named in RIGHTS.md
|
|
9
|
+
* none a grey placeholder of the recorded size and "needs its own licence"
|
|
10
|
+
*
|
|
11
|
+
* At most 50 MB in all. Each entry of the remix's manifest records where it came from (`remixOf`), so RIGHTS.md and the
|
|
12
|
+
* credits carry over; nothing is fetched from anywhere but the original site.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { LIMITS, checkGlb } from '../assets/safety.mjs';
|
|
18
|
+
import { readManifest, syncCredits, writeManifest, writeRights } from './asset-manifest.mjs';
|
|
19
|
+
|
|
20
|
+
export const REMIX_ASSET_CAP = 50 * 1024 * 1024;
|
|
21
|
+
const SAFE_PATH = /^public\/[A-Za-z0-9._/-]{1,200}$/;
|
|
22
|
+
|
|
23
|
+
/** `game remix` after the text files are written: the original's assets, or placeholders. */
|
|
24
|
+
export async function fetchRemixAssets(root, id, source, { credit = {}, cap = REMIX_ASSET_CAP, log = () => {} } = {}) {
|
|
25
|
+
const base = new URL(String(source));
|
|
26
|
+
const at = new URL(base.pathname.replace(/source\.json$/, 'assets.json'), base);
|
|
27
|
+
let body = null;
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(at, { signal: AbortSignal.timeout(20_000) });
|
|
30
|
+
if (res.ok) body = await res.json();
|
|
31
|
+
} catch { body = null; }
|
|
32
|
+
if (body?.kind !== 'homie-game-assets' || !Array.isArray(body.assets)) return { carried: false, why: 'the original serves no assets.json (a game made before 0.22.0, or one with no models)', fetched: [], placeholders: [], bytes: 0 };
|
|
33
|
+
const gdir = join(root, 'games', id);
|
|
34
|
+
const manifest = readManifest(root, id);
|
|
35
|
+
const fetched = []; const placeholders = []; const failed = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
const { placeholderGlb, sha256 } = await import('./optimise.mjs').catch(() => ({}));
|
|
38
|
+
for (const a of body.assets) {
|
|
39
|
+
const entry = manifest.assets.find((x) => x.id === a.id);
|
|
40
|
+
if (!entry) continue;
|
|
41
|
+
entry.from = { ...(entry.from ?? {}), remixOf: { studio: credit.studio ?? null, game: credit.game ?? null, source: String(base), asset: a.id } };
|
|
42
|
+
// The original studio owns what it made or generated; this remix does not.
|
|
43
|
+
if (entry.license?.owner === 'studio') entry.license = { ...entry.license, owner: credit.studio ?? 'the original studio' };
|
|
44
|
+
const files = (a.files ?? []).filter((f) => SAFE_PATH.test(String(f.path ?? '')) && !String(f.path).split('/').includes('..'));
|
|
45
|
+
if (a.license?.remix === 'include' && files.length && files.every((f) => f.url && f.sha256)) {
|
|
46
|
+
let ok = true;
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
const url = new URL(String(f.url), at);
|
|
49
|
+
if (url.origin !== at.origin) { failed.push({ asset: a.id, why: `${f.url} is on another site` }); ok = false; break; }
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
52
|
+
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
53
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
54
|
+
total += bytes.byteLength;
|
|
55
|
+
if (total > cap) throw new Error(`the remix's assets passed ${Math.round(cap / 1024 / 1024)} MB`);
|
|
56
|
+
const sum = createHash('sha256').update(bytes).digest('hex');
|
|
57
|
+
if (sum !== f.sha256) throw new Error('its SHA-256 does not match the original\'s record');
|
|
58
|
+
if (/\.glb$/i.test(f.path)) { const s = checkGlb(bytes, LIMITS.game); if (!s.ok) throw new Error(s.problems[0]); }
|
|
59
|
+
mkdirSync(dirname(join(gdir, f.path)), { recursive: true });
|
|
60
|
+
writeFileSync(join(gdir, f.path), bytes);
|
|
61
|
+
} catch (error) { failed.push({ asset: a.id, file: f.path, why: error.message }); ok = false; break; }
|
|
62
|
+
}
|
|
63
|
+
if (ok) { fetched.push({ asset: a.id, files: files.map((f) => f.path) }); continue; }
|
|
64
|
+
}
|
|
65
|
+
// Not carried: a grey box of the size it had, so the game still runs, and a plain note.
|
|
66
|
+
const model = (entry.files ?? []).find((f) => f.role === 'model' && SAFE_PATH.test(String(f.path ?? '')));
|
|
67
|
+
if (model && placeholderGlb) {
|
|
68
|
+
const size = a.size ?? entry.measured?.box?.size ?? [0.5, entry.measured?.heightM ?? 0.5, 0.5];
|
|
69
|
+
const glb = Buffer.from(await placeholderGlb(size, { name: `${a.id}-placeholder` }));
|
|
70
|
+
mkdirSync(dirname(join(gdir, model.path)), { recursive: true });
|
|
71
|
+
if (!existsSync(join(gdir, model.path))) writeFileSync(join(gdir, model.path), glb);
|
|
72
|
+
model.sha256 = sha256(glb);
|
|
73
|
+
model.bytes = glb.byteLength;
|
|
74
|
+
}
|
|
75
|
+
entry.placeholder = true;
|
|
76
|
+
entry.license = { ...(entry.license ?? {}), remix: 'none', notes: `${a.license?.remix === 'reference' ? `Fetch it from its origin${a.from?.origin ? ` (${a.from.origin})` : ''} under its own licence` : 'Needs its own licence'}: this remix has a grey placeholder of the same size in its place.` };
|
|
77
|
+
placeholders.push({ asset: a.id, why: a.license?.remix === 'include' ? (failed.find((x) => x.asset === a.id)?.why ?? 'not served') : `its licence (${a.license?.kind ?? 'none'}) does not let a remix carry it` });
|
|
78
|
+
}
|
|
79
|
+
writeManifest(root, id, manifest);
|
|
80
|
+
writeRights(root, id, manifest);
|
|
81
|
+
syncCredits(root, id, manifest);
|
|
82
|
+
log(`remix assets: ${fetched.length} carried, ${placeholders.length} placeholder(s)`);
|
|
83
|
+
return { carried: true, fetched, placeholders, failed, bytes: total };
|
|
84
|
+
}
|
package/lib/render3d.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STILL PICTURES FROM THE ENGINE (free, on this computer): the style board's swatches, the lineup, the starter
|
|
3
|
+
* library's thumbnails. assets/render-page.ts (three.js, GLTFLoader with meshopt) is bundled once by esbuild and run
|
|
4
|
+
* in one headless Chrome on this computer's GPU (SwiftShader where there is none: slow, and fine for stills).
|
|
5
|
+
*
|
|
6
|
+
* await withRenderer(async (r) => {
|
|
7
|
+
* const jpg = await r.swatch(tokens, models, { title }) // a data: URL
|
|
8
|
+
* const l = await r.lineup(models, tokens) // { front, quarter, silhouettes, rows }
|
|
9
|
+
* const t = await r.thumb({ id, glb }) // { image, size }
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* Models go in as { id, glb: <base64>, label }. Nothing is fetched but Google Fonts (the swatch's title font).
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
18
|
+
import { PACKAGE_ROOT } from './studio.mjs';
|
|
19
|
+
|
|
20
|
+
let bundled = null;
|
|
21
|
+
|
|
22
|
+
/** The render page's script, bundled with three.js from wherever this package's dependencies are installed. */
|
|
23
|
+
export async function renderScript() {
|
|
24
|
+
if (bundled) return bundled;
|
|
25
|
+
let esbuild;
|
|
26
|
+
try { esbuild = await import('esbuild'); } catch { throw new Error('esbuild is not installed (it comes with @homie-rocks/studio: npm install)'); }
|
|
27
|
+
const res = await (esbuild.default ?? esbuild).build({
|
|
28
|
+
entryPoints: [join(PACKAGE_ROOT, 'assets', 'render-page.ts')], bundle: true, format: 'iife', target: 'es2022', minify: true, write: false,
|
|
29
|
+
absWorkingDir: PACKAGE_ROOT, logLevel: 'silent', platform: 'browser',
|
|
30
|
+
}).catch((error) => { throw new Error(`the render page did not build: ${error.errors?.[0]?.text ?? error.message} (three.js comes with @homie-rocks/studio: npm install)`); });
|
|
31
|
+
bundled = res.outputFiles[0].text;
|
|
32
|
+
return bundled;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Model bytes as the page takes them. */
|
|
36
|
+
export const modelIn = (id, bytes, extra = {}) => ({ id, glb: Buffer.from(bytes).toString('base64'), ...extra });
|
|
37
|
+
|
|
38
|
+
/** A data: URL into bytes. */
|
|
39
|
+
export function dataUrlBytes(url) {
|
|
40
|
+
const m = /^data:([^;,]+);base64,(.*)$/s.exec(String(url));
|
|
41
|
+
if (!m) throw new Error('not a base64 data: URL');
|
|
42
|
+
return { type: m[1], bytes: Buffer.from(m[2], 'base64') };
|
|
43
|
+
}
|
|
44
|
+
export function saveDataUrl(url, file) { const { bytes } = dataUrlBytes(url); writeFileSync(file, bytes); return bytes.length; }
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run `fn` with a renderer: { swatch, lineup, thumb, renderer: 'the WebGL renderer name' }. One Chrome for the whole
|
|
48
|
+
* call, closed after. Throws in plain words when there is no Chrome or no WebGL.
|
|
49
|
+
*/
|
|
50
|
+
export async function withRenderer(fn, { log = () => {} } = {}) {
|
|
51
|
+
const chrome = findChrome();
|
|
52
|
+
if (!chrome) throw new Error(noChrome());
|
|
53
|
+
let puppeteer;
|
|
54
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { throw new Error('puppeteer-core is not installed (it comes with @homie-rocks/studio: npm install)'); }
|
|
55
|
+
const script = await renderScript();
|
|
56
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-render-'));
|
|
57
|
+
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 300_000, args: [...chromeArgs(), '--no-first-run', '--no-default-browser-check', '--hide-scrollbars'] });
|
|
58
|
+
try {
|
|
59
|
+
const page = await browser.newPage();
|
|
60
|
+
const errors = [];
|
|
61
|
+
page.on('pageerror', (e) => errors.push(String(e?.message ?? e).slice(0, 300)));
|
|
62
|
+
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
|
|
63
|
+
await page.setContent('<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:#000}canvas{display:block}</style></head><body></body></html>', { waitUntil: 'load' });
|
|
64
|
+
await page.addScriptTag({ content: script });
|
|
65
|
+
const ok = await page.waitForFunction(() => window.homieRender?.ready === true, { timeout: 60_000 }).then(() => true).catch(() => false);
|
|
66
|
+
if (!ok) throw new Error(`the render page did not start${errors.length ? `: ${errors[0]}` : ''}`);
|
|
67
|
+
const renderer = await page.evaluate(() => { try { const c = document.createElement('canvas'); const gl = c.getContext('webgl2'); const d = gl?.getExtension('WEBGL_debug_renderer_info'); return gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL'; } catch { return null; } });
|
|
68
|
+
log(`rendering on ${renderer}`);
|
|
69
|
+
const call = (name, ...args) => page.evaluate((n, a) => window.homieRender[n](...a), name, args).catch((e) => { throw new Error(`${name} failed: ${errors.at(-1) ?? e.message}`); });
|
|
70
|
+
return await fn({
|
|
71
|
+
renderer,
|
|
72
|
+
swatch: (tokens, models = [], opts = {}) => call('swatch', tokens, models, opts),
|
|
73
|
+
lineup: (models, tokens, opts = {}) => call('lineup', models, tokens, opts),
|
|
74
|
+
thumb: (model, opts = {}) => call('thumb', model, opts),
|
|
75
|
+
});
|
|
76
|
+
} finally {
|
|
77
|
+
await browser.close().catch(() => {});
|
|
78
|
+
rmSync(profile, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
package/lib/scaffold.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
|
28
28
|
import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
|
|
29
29
|
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
30
30
|
import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
|
|
31
|
+
import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
|
|
31
32
|
import { themeFile } from './site.mjs';
|
|
32
33
|
|
|
33
34
|
export const COMPAT_DATE = '2026-06-01';
|
|
@@ -106,6 +107,7 @@ directory lists its games; homie.rocks does not host them.
|
|
|
106
107
|
| --- | --- |
|
|
107
108
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`; and, when it has them, \`tunables.json\` (the numbers that shape how it feels: the Game Lab's sliders, kept values written back) and \`lab.json\` (the Game Lab's takes). |
|
|
108
109
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`. Without storage the site serves each file itself (up to 25 MiB). Once the studio has storage (see below), its big media (over 1 MiB, or left out of git) lives in its own R2: every deploy uploads it, checks it by SHA-256 and serves it from R2 at the same address. Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
110
|
+
| \`games/<id>/assets/\` | Every model and texture a game ships, with where it came from and its licence (\`manifest.json\`), and \`RIGHTS.md\` in plain words; \`games/<id>/codex/decisions.json\` (private, like the codex) is its look as decisions, and \`style.json\` the palette, fonts, light and camera the game draws with. \`art/<slug>/\` holds art jobs (concepts, receipts; \`raw/\` is git-ignored). |
|
|
109
111
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
110
112
|
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
111
113
|
| \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
|
|
@@ -264,6 +266,26 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
264
266
|
Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
|
|
265
267
|
block anything.
|
|
266
268
|
|
|
269
|
+
## Art direction and models
|
|
270
|
+
|
|
271
|
+
- **A game's look is a set of decisions** (\`games/<id>/codex/decisions.json\`, drawn in the codex's Art direction
|
|
272
|
+
tab): render style, palette, shape, proportions, materials, light, camera, fonts, effects; the cast, its library
|
|
273
|
+
family, scale and phone budgets. \`npx --no-install homie-studio style init <id> --prompt "<the person's words>"\`
|
|
274
|
+
picks every one automatically with a why. The person can steer one ("warmer"), lock one, or explore three
|
|
275
|
+
directions drawn by the game engine itself (\`style board <id>\`, free). The first asset built on a decision pins
|
|
276
|
+
it; a locked one changes only with the person's yes after \`style blast\` shows what goes stale and what remaking
|
|
277
|
+
it would cost. Nothing is ever remade by itself. The Homie plugin's \`style\` skill has the rest.
|
|
278
|
+
- **Models come free first**: the engine, then Homie's CC0 starter library (\`assets find "<words>"\`, then
|
|
279
|
+
\`assets add <id> <item>\`, copied in, never hot-linked), then the person's own files (\`assets add <id> --file …
|
|
280
|
+
--license …\`), then generated props on the person's own fal account under a budget with receipts (the plugin's
|
|
281
|
+
\`models\` skill). Every model is checked (no external URIs, no oversized files), made phone-sized and recorded
|
|
282
|
+
with its licence; \`RIGHTS.md\` and the credits page follow. \`assets check <id>\` holds a game to the phone
|
|
283
|
+
budgets; \`assets lineup <id>\` shows everything at true scale. A three.js game loads models through
|
|
284
|
+
\`@homie-rocks/studio/assets\` (\`createModels\`); \`game new <id> --from gem-rush-3d\` starts one.
|
|
285
|
+
- **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
|
|
286
|
+
generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
|
|
287
|
+
checked by SHA-256, and a grey placeholder for the rest.
|
|
288
|
+
|
|
267
289
|
## Running live games (the back office)
|
|
268
290
|
|
|
269
291
|
- The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
|
|
@@ -485,6 +507,11 @@ music/**/*.flac
|
|
|
485
507
|
videos/**/*.mp4
|
|
486
508
|
videos/**/*.mov
|
|
487
509
|
videos/**/*.webm
|
|
510
|
+
# 3D: raw provider output, high-poly sources and Blender files stay on this computer (or the studio's R2): never shipped, never committed. The phone-sized model in games/<id>/public/models/ is committed.
|
|
511
|
+
art/**/raw/
|
|
512
|
+
*.blend
|
|
513
|
+
*.blend1
|
|
514
|
+
*.fbx
|
|
488
515
|
`;
|
|
489
516
|
|
|
490
517
|
/** Every file a new studio gets, relative to its folder. */
|
|
@@ -545,6 +572,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
545
572
|
[`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
|
|
546
573
|
[`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
|
|
547
574
|
[`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
|
|
575
|
+
[`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
|
|
548
576
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
549
577
|
'.claude/skills/.gitkeep': '',
|
|
550
578
|
};
|
|
@@ -637,13 +665,22 @@ export function ensureServersMigration(root) {
|
|
|
637
665
|
}
|
|
638
666
|
|
|
639
667
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
668
|
+
/** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
|
|
669
|
+
function ensureChatMigration(root) {
|
|
670
|
+
const file = join(root, 'site', 'migrations', CHAT_MIGRATION_FILE);
|
|
671
|
+
if (existsSync(file)) return null;
|
|
672
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
673
|
+
writeFileSync(file, CHAT_MIGRATION);
|
|
674
|
+
return `site/migrations/${CHAT_MIGRATION_FILE}`;
|
|
675
|
+
}
|
|
676
|
+
|
|
640
677
|
export function ensureMigrations(root) {
|
|
641
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root)].filter(Boolean);
|
|
678
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root)].filter(Boolean);
|
|
642
679
|
}
|
|
643
680
|
|
|
644
681
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
645
682
|
export function migrationWord(file) {
|
|
646
|
-
return /players/.test(file) ? 'player accounts and cloud saves' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
683
|
+
return /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
647
684
|
}
|
|
648
685
|
|
|
649
686
|
/**
|