@homie-rocks/studio 0.24.4 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +50 -3
  3. package/assets/animate.ts +521 -0
  4. package/assets/render-page.ts +77 -4
  5. package/bin/homie-studio.mjs +13 -4
  6. package/chat/CHAT.md +36 -5
  7. package/lib/art-cli.mjs +105 -5
  8. package/lib/art-tools.mjs +168 -1
  9. package/lib/asset-check.mjs +72 -5
  10. package/lib/build.mjs +3 -0
  11. package/lib/characters.mjs +380 -0
  12. package/lib/clips.mjs +417 -0
  13. package/lib/decisions.mjs +38 -8
  14. package/lib/mcp-tools.mjs +1 -1
  15. package/lib/mcp.mjs +2 -2
  16. package/lib/optimise.mjs +79 -3
  17. package/lib/render3d.mjs +2 -0
  18. package/lib/rig.mjs +431 -0
  19. package/lib/scaffold.mjs +17 -4
  20. package/lib/studio.mjs +18 -3
  21. package/lib/style-board.mjs +3 -1
  22. package/lib/template-history.json +78 -0
  23. package/mcp/ui/animation.js +56 -0
  24. package/mcp/ui/card.css +4 -0
  25. package/mcp/ui/cast.js +36 -2
  26. package/mcp/ui/lineup.js +7 -7
  27. package/netplay/NETPLAY.md +9 -5
  28. package/package.json +5 -1
  29. package/site/SITE.md +4 -0
  30. package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
  31. package/starters/hero-rush-3d/assets/manifest.json +1471 -0
  32. package/starters/hero-rush-3d/credits.json +40 -0
  33. package/starters/hero-rush-3d/game.json +11 -0
  34. package/starters/hero-rush-3d/index.html +20 -0
  35. package/starters/hero-rush-3d/lab.json +32 -0
  36. package/starters/hero-rush-3d/src/main.ts +2361 -0
  37. package/starters/hero-rush-3d/style.json +68 -0
  38. package/starters/hero-rush-3d/tunables.json +32 -0
  39. package/worker/chat-page.mjs +173 -28
  40. package/worker/chat.mjs +4 -1
  41. package/worker/version.mjs +1 -1
@@ -5,9 +5,14 @@
5
5
  * glTF-Validator; triangles, materials, picture sizes, bones and bytes against its tier's budget
6
6
  * (the game's cast.tiers decision, else the phone defaults); the pivot at the bottom centre; its height against
7
7
  * the card's; its licence (lib/asset-manifest.mjs); whether a decision it was made under has moved on
8
+ * characters four influences a vertex (three.js reads no more), bones within the tier, its skeleton's clip library
9
+ * present, and every verb the game needs (anim.clips) in it
10
+ * clips a skeleton's clip library (kind clip): at most 1 MB (3 MB hard), sampled at no more than 30 a second
8
11
  * the game shipped models with no record; draw calls, triangles and picture memory summed (each asset times its
9
12
  * `placements`, instanced ones drawn once); the first-play download from the last build (gzip); a
10
- * committed binary over 5 MB under games/ (it belongs in R2, never in git)
13
+ * committed binary over 5 MB under games/ (it belongs in R2, never in git); skinning on a phone: the
14
+ * room's players times the heaviest character's skinned vertices and bones, against SKINNING (beyond it,
15
+ * far characters must pose less often: @homie-rocks/studio/animate's crowd())
11
16
  *
12
17
  * The result also goes to .studio/art/<id>/check.json, which the Studio mod's Art tab and the cards read.
13
18
  */
@@ -28,10 +33,15 @@ export const BUDGETS = Object.freeze({
28
33
  kit: { triangles: 1000, texturePx: 512, kb: 200, bones: 0, materials: 1 },
29
34
  scene: { drawCalls: 100, triangles: 150_000, textureMB: 48, firstPlayMB: 5 },
30
35
  });
36
+ /**
37
+ * Skinning on a phone, every frame (judgment, from the house's 32-player rooms; the perf skill's measured gate decides):
38
+ * the vertices the GPU skins and the bones the CPU poses. Characters past `crowdFrom` need crowd mode.
39
+ */
40
+ export const SKINNING = Object.freeze({ vertices: 60_000, bones: 1_200, crowdFrom: 12, clipKB: 1024, clipHardKB: 3072, clipRate: 30 });
31
41
  export const HARD_CAPS = Object.freeze({ triangles: { hero: 15000, npc: 6000, prop: 8000, signature: 8000, kit: 3000 }, texturePx: 2048, kb: 5120, bones: 64, materials: 2, scene: { drawCalls: 150, triangles: 250_000, textureMB: 64, firstPlayMB: 8 } });
32
42
  const BIG_FILE = 5 * 1024 * 1024;
33
43
 
34
- const tierOf = (a) => (BUDGETS[a.tier] ? a.tier : a.kind === 'character' ? 'hero' : a.kind === 'creature' ? 'npc' : a.kind === 'kit' || a.kind === 'environment' ? 'kit' : 'prop');
44
+ const tierOf = (a) => (a.kind === 'clip' ? 'clip' : BUDGETS[a.tier] ? a.tier : a.kind === 'character' ? 'hero' : a.kind === 'creature' ? 'npc' : a.kind === 'kit' || a.kind === 'environment' ? 'kit' : 'prop');
35
45
 
36
46
  /** The budgets this game holds itself to: its cast.tiers decision over the phone defaults, never past a hard cap. */
37
47
  export function budgetsFor(doc) {
@@ -97,11 +107,16 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
97
107
  const rows = [];
98
108
  let tris = 0; let draws = 0; let texBytes = 0;
99
109
  const seenFiles = new Set();
110
+ const { verbsFor } = await import('./characters.mjs');
111
+ const needVerbs = verbsFor(doc);
112
+ const libs = new Map(manifest.assets.filter((x) => x.kind === 'clip').map((x) => [x.rig?.skeleton, x]));
113
+ let heaviest = { vertices: 0, bones: 0, id: null };
100
114
  for (const a of manifest.assets) {
101
115
  const problems = []; const warnings = [];
102
116
  const tier = tierOf(a);
103
117
  const b = budgets[tier] ?? budgets.prop;
104
118
  const n = Math.max(1, Number(a.placements ?? 1) || 1);
119
+ if (a.kind === 'clip') { rows.push(await clipRow(gdir, a, tools)); continue; }
105
120
  const model = (a.files ?? []).find((f) => f.role === 'model');
106
121
  let m = null;
107
122
  if (model) {
@@ -126,6 +141,19 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
126
141
  else if (m.materials > b.materials) warnings.push(`${m.materials} materials (the budget is ${b.materials}: one draw call per material)`);
127
142
  if (bytes.byteLength / 1024 > b.kb) problems.push(`${kb(bytes.byteLength)} KB (a ${tier}'s budget is ${b.kb} KB)`);
128
143
  if (m.bones > (b.bones || HARD_CAPS.bones)) problems.push(`${m.bones} bones (a ${tier}'s budget is ${b.bones || HARD_CAPS.bones})`);
144
+ if (m.influences > 4) problems.push(`${m.influences} joints move one vertex (JOINTS_1): three.js reads 4, so the rest are dropped and the mesh tears; export with 4 influences a vertex`);
145
+ if (m.skinnedVertices > heaviest.vertices) heaviest = { ...heaviest, vertices: m.skinnedVertices, id: a.id };
146
+ if (m.bones > heaviest.bones) heaviest.bones = m.bones;
147
+ if (a.rig) {
148
+ const lib = libs.get(a.rig.skeleton);
149
+ const animsFile = a.rig.anims ? join(gdir, a.rig.anims) : null;
150
+ if (!animsFile || !existsSync(animsFile)) problems.push(`its clip library ${a.rig.anims ?? '(none named)'} is missing: homie-studio anim add ${id} ${a.id} --verbs ${needVerbs.join(',')}`);
151
+ else {
152
+ const have = lib?.clips?.map((c) => c.verb) ?? a.rig.verbs ?? [];
153
+ const lacking = needVerbs.filter((v) => !have.includes(v));
154
+ if (lacking.length) warnings.push(`the game's clips (anim.clips) name ${lacking.join(', ')}, which ${a.id}'s clip library has not: homie-studio anim add ${id} ${a.id} --verbs ${lacking.join(',')}`);
155
+ }
156
+ }
129
157
  if (m.pivot && !(m.pivot.bottom && m.pivot.centred)) warnings.push('its pivot is not at the bottom centre (assets optimise puts it there)');
130
158
  const want = Number(a.heightM ?? a.card?.heightM ?? 0);
131
159
  if (want > 0 && m.heightM && Math.abs(m.heightM / want - 1) > 0.1) warnings.push(`${m.heightM} m tall; its card says ${want} m`);
@@ -141,14 +169,23 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
141
169
  if (a.route === 'procedural' && a.measured) { tris += (a.measured.tris ?? 0) * n; draws += a.instanced ? (a.measured.drawCalls ?? 1) : (a.measured.drawCalls ?? 1) * n; }
142
170
  rows.push({ id: a.id, kind: a.kind, tier, route: a.route, ok: problems.length === 0, problems, warnings, measured: m ? { tris: m.triangles, drawCalls: m.drawCalls, materials: m.materials, maxTexturePx: m.maxTexturePx, textureKB: kb(m.textureGpuBytes), bones: m.bones, heightM: m.heightM, kb: model ? kb(statSync(join(gdir, model.path)).size) : null } : null, budget: b, placements: n });
143
171
  }
172
+ // Skinning on a phone: the room's players, each the heaviest character (the worst case a room can draw).
173
+ let players = 8;
174
+ try { players = Math.max(1, Number(JSON.parse(readFileSync(join(gdir, 'game.json'), 'utf8')).players?.max ?? 8) || 8); } catch { players = 8; }
175
+ const skinning = heaviest.vertices ? { players, character: heaviest.id, vertices: heaviest.vertices * players, bones: heaviest.bones * players, budget: { vertices: SKINNING.vertices, bones: SKINNING.bones } } : null;
144
176
  const licence = licenceProblems(root, id, manifest, { public: publicSource(root, id) });
145
177
  const stale = doc ? staleAssets(doc, manifest) : [];
146
178
  const unrecorded = unrecordedModels(root, id, manifest);
147
179
  const bigFiles = bigCommittedFiles(root).filter((f) => f.path.startsWith(`games/${id}/`));
148
180
  const firstPlay = firstPlayBytes(root, id);
149
181
  const s = budgets.scene;
150
- const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), firstPlayMB: firstPlay ? +(firstPlay.gzip / 1024 / 1024).toFixed(2) : null };
182
+ const totals = { assets: manifest.assets.length, triangles: tris, drawCalls: draws, textureMB: +(texBytes / 1024 / 1024).toFixed(1), firstPlayMB: firstPlay ? +(firstPlay.gzip / 1024 / 1024).toFixed(2) : null, skinnedVertices: skinning?.vertices ?? 0, bones: skinning?.bones ?? 0 };
151
183
  const problems = [];
184
+ if (skinning && (skinning.vertices > SKINNING.vertices || skinning.bones > SKINNING.bones)) {
185
+ const crowd = `far characters must pose less often: call crowd(characters, camera) from @homie-rocks/studio/animate every frame (and lighter characters, or fewer, for the rest)`;
186
+ if (players >= SKINNING.crowdFrom) problems.push(`a room of ${players} skins about ${skinning.vertices.toLocaleString('en-US')} vertices and poses ${skinning.bones.toLocaleString('en-US')} bones a frame on a phone (budget ${SKINNING.vertices.toLocaleString('en-US')} and ${SKINNING.bones.toLocaleString('en-US')}): ${crowd}`);
187
+ else problems.push(`a room of ${players} skins about ${skinning.vertices.toLocaleString('en-US')} vertices and poses ${skinning.bones.toLocaleString('en-US')} bones a frame (budget ${SKINNING.vertices.toLocaleString('en-US')} and ${SKINNING.bones.toLocaleString('en-US')}): a lighter character (assets optimise --triangles), or ${crowd}`);
188
+ }
152
189
  if (totals.drawCalls > s.drawCalls) problems.push(`about ${totals.drawCalls} draw calls in a scene (the phone budget is ${s.drawCalls}): instance repeated props (placements with instanced: true), or fewer materials`);
153
190
  if (totals.triangles > s.triangles) problems.push(`about ${totals.triangles.toLocaleString('en-US')} triangles in a scene (the phone budget is ${s.triangles.toLocaleString('en-US')})`);
154
191
  if (totals.textureMB > s.textureMB) problems.push(`${totals.textureMB} MB of picture memory (the phone budget is ${s.textureMB} MB; an iPhone page dies near 100 MB)`);
@@ -156,7 +193,7 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
156
193
  for (const l of licence.filter((x) => x.level === 'refuse')) problems.push(`${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
157
194
  for (const f of bigFiles) problems.push(`${f.path} is ${(f.bytes / 1024 / 1024).toFixed(1)} MB in git: big files go to the studio's R2 (homie-studio storage add, then media move), raw files to art/<slug>/raw/ (git-ignored)`);
158
195
  const ok = rows.every((r) => r.ok) && problems.length === 0;
159
- const result = { ok, command: 'assets check', game: id, rows, totals, budgets: { ...s, tiers: Object.fromEntries(['hero', 'npc', 'prop', 'signature', 'kit'].map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, bigFiles, firstPlay, problems, at: new Date().toISOString() };
196
+ const result = { ok, command: 'assets check', game: id, rows, totals, skinning, budgets: { ...s, skinning: SKINNING, tiers: Object.fromEntries(['hero', 'npc', 'prop', 'signature', 'kit'].map((t) => [t, budgets[t]])) }, licence, stale, unrecorded, bigFiles, firstPlay, problems, at: new Date().toISOString() };
160
197
  if (write) {
161
198
  const dir = join(root, '.studio', 'art', id);
162
199
  mkdirSync(dir, { recursive: true });
@@ -165,6 +202,34 @@ export async function assetsCheck(root, id, { validate = true, write = true } =
165
202
  return result;
166
203
  }
167
204
 
205
+ /** A clip library's row: its file there and recorded, its size and sample rate against SKINNING, what it holds. */
206
+ async function clipRow(gdir, a, tools) {
207
+ const problems = []; const warnings = [];
208
+ const f = (a.files ?? []).find((x) => x.role === 'clip');
209
+ let measured = null;
210
+ if (!f) problems.push('no clip library file recorded');
211
+ else if (!existsSync(join(gdir, f.path))) problems.push(`${f.path} is missing`);
212
+ else {
213
+ const bytes = readFileSync(join(gdir, f.path));
214
+ const safe = checkGlb(bytes, LIMITS.game);
215
+ if (!safe.ok) problems.push(...safe.problems.map((p) => `unsafe: ${p}`));
216
+ const k = Math.round(bytes.byteLength / 1024);
217
+ if (k > SKINNING.clipHardKB) problems.push(`${k} KB of clips (at most ${SKINNING.clipHardKB} KB): fewer verbs, or resample`);
218
+ else if (k > SKINNING.clipKB) warnings.push(`${k} KB of clips (the budget is ${SKINNING.clipKB} KB a skeleton)`);
219
+ if (f.sha256 && tools && tools.sha256(bytes) !== f.sha256) warnings.push('the file changed since it was recorded (its SHA-256 differs)');
220
+ if (safe.ok && tools) {
221
+ const ins = await tools.inspectModel(bytes, { limits: LIMITS.game }).catch(() => null);
222
+ const m = ins?.measured;
223
+ if (m) {
224
+ measured = { clips: m.clips.length, verbs: m.clips, kb: k, rate: m.clipRate, seconds: m.clipSeconds, keys: m.clipKeys };
225
+ if (m.clipRate > SKINNING.clipRate + 0.5) warnings.push(`sampled at about ${m.clipRate} keys a second (30 is plenty: homie-studio anim add bakes at 30)`);
226
+ if (!m.clips.length) problems.push('it holds no clips');
227
+ }
228
+ }
229
+ }
230
+ return { id: a.id, kind: 'clip', tier: 'clip', route: a.route, ok: problems.length === 0, problems, warnings, measured: measured ? { tris: 0, drawCalls: 0, materials: 0, maxTexturePx: 0, textureKB: 0, bones: 0, heightM: null, kb: measured.kb, clips: measured.clips, verbs: measured.verbs, rate: measured.rate } : null, budget: { kb: SKINNING.clipKB }, placements: 1 };
231
+ }
232
+
168
233
  /** Whether a game's source is shared (game.json share.source is not false) and it is meant to be public. */
169
234
  export function publicSource(root, id) {
170
235
  try { const g = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); return g.share?.source !== false; } catch { return true; }
@@ -175,13 +240,15 @@ export function checkLines(r) {
175
240
  const lines = [`${r.ok ? 'PASS' : 'NOT YET'}: ${r.game}'s ${r.totals.assets} asset${r.totals.assets === 1 ? '' : 's'} against the phone budgets`];
176
241
  for (const row of r.rows) {
177
242
  const m = row.measured;
178
- lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (${row.kind}, ${row.tier}, ${row.route})${m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}` : ''}`);
243
+ if (row.kind === 'clip') { lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (clip library, ${row.route})${m ? `: ${m.clips} clips (${(m.verbs ?? []).join(', ')}), ${m.kb} KB, ${m.rate} keys a second` : ''}`); for (const p of row.problems) lines.push(` ${p}`); for (const w of row.warnings) lines.push(` note: ${w}`); continue; }
244
+ lines.push(` ${row.ok ? 'ok ' : 'FIX '} ${row.id} (${row.kind}, ${row.tier}, ${row.route})${m ? `: ${m.tris} triangles, ${m.drawCalls} draw${m.drawCalls === 1 ? '' : 's'}, ${m.kb} KB, ${m.maxTexturePx ? `${m.maxTexturePx} px pictures` : 'no pictures'}${m.heightM ? `, ${m.heightM} m` : ''}${m.bones ? `, ${m.bones} bones` : ''}` : ''}`);
179
245
  for (const p of row.problems) lines.push(` ${p}`);
180
246
  for (const w of row.warnings) lines.push(` note: ${w}`);
181
247
  }
182
248
  for (const u of r.unrecorded) lines.push(` FIX ${u}: shipped with no record (homie-studio assets add ${r.game} --file ${u} --license <kind>)`);
183
249
  const s = r.budgets;
184
250
  lines.push(` scene: about ${r.totals.drawCalls} draw calls (budget ${s.drawCalls}), ${r.totals.triangles.toLocaleString('en-US')} triangles (${s.triangles.toLocaleString('en-US')}), ${r.totals.textureMB} MB of picture memory (${s.textureMB} MB)${r.totals.firstPlayMB !== null ? `, ${r.totals.firstPlayMB} MB first play (${s.firstPlayMB} MB)` : ', first play: build first'}`);
251
+ if (r.skinning) lines.push(` skinning: a room of ${r.skinning.players} skins about ${r.skinning.vertices.toLocaleString('en-US')} vertices (budget ${r.skinning.budget.vertices.toLocaleString('en-US')}) and poses ${r.skinning.bones.toLocaleString('en-US')} bones (${r.skinning.budget.bones.toLocaleString('en-US')}) a frame on a phone; the heaviest is ${r.skinning.character}`);
185
252
  for (const l of r.licence) lines.push(` ${l.level === 'refuse' ? 'FIX ' : 'note'} licence ${l.asset}: ${l.problem}${l.fix ? ` (${l.fix})` : ''}`);
186
253
  for (const st of r.stale) lines.push(` stale ${st.id}: made under an older ${st.decisions.map((d) => d.id).join(', ')} (homie-studio style blast shows the cost; nothing is remade by itself)`);
187
254
  for (const p of r.problems.filter((x) => !/^[a-z0-9-]+: /.test(x))) lines.push(` FIX ${p}`);
package/lib/build.mjs CHANGED
@@ -47,6 +47,7 @@ import { STUDIO_VERSION } from './version.mjs';
47
47
  import { licenseOf, remixRow } from '../worker/license.mjs';
48
48
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
49
49
  import { chatProblems } from '../worker/chat.mjs';
50
+ import { screenChatProblems } from '../worker/chat-page.mjs';
50
51
  import { vocabularyOf } from '../worker/brain.mjs';
51
52
  import { shopForBuild } from './shop.mjs';
52
53
  import { audienceOf } from '../worker/shop-rules.mjs';
@@ -323,6 +324,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
323
324
  if (chatBad.length) throw new Error(`games/${g.id}/game.json: ${chatBad.join('; ')}`);
324
325
  // A game's own decisions (NETPLAY.md section 20): game.json "decide" is true or false (the Worker answers net.decide only then).
325
326
  if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
327
+ // Where chat sits on the screen (game.json "screen": { "chat" }, 0.24.5): a wrong field is the default on the page.
328
+ for (const p of screenChatProblems(g.screen?.chat)) log(`warning: games/${g.id}/game.json: ${p}; the play page uses the default there`);
326
329
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
327
330
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
328
331
  // Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
@@ -0,0 +1,380 @@
1
+ /**
2
+ * CHARACTERS AND THEIR CLIPS IN A GAME (free, on this computer): a rigged character comes in from the starter library,
3
+ * the person's files or a provider, and leaves as two files a phone can afford:
4
+ *
5
+ * public/models/<asset>.glb the character: its joints renamed to the skeleton standard (lib/rig.mjs), helper
6
+ * bones that move nothing removed, every part and every held thing it keeps merged
7
+ * into one skinned mesh (one draw call), phone-sized, no clips
8
+ * public/anims/<skeleton>.glb the clip library of its skeleton: one clip per verb the game uses (idle, run, jump,
9
+ * attack, hit, die, ...), its own where it has them, else RETARGETED from a source
10
+ * (KayKit's CC0 humanoid clips by default) at build time; every character with that
11
+ * skeleton shares it
12
+ *
13
+ * Both are recorded in assets/manifest.json (the character, kind character or creature; the library, kind clip) with
14
+ * where every clip came from and its licence, so RIGHTS.md, the remix carry-over and `assets check` see them. The model
15
+ * names its clip library in its scene's extras (homie.anims), so the game's `loadCharacter(models, url)` finds it.
16
+ *
17
+ * addCharacter(root, game, { item | file, ... }) in, optimised, clips baked, recorded
18
+ * bakeClips(root, game, asset, { verbs, from }) more verbs for a character's skeleton (retargeted from a source)
19
+ * animPlan(root, game) each character: its skeleton, its clips against the verbs the game
20
+ * needs (anim.clips), what is missing and where it would come from
21
+ * animPreview(root, game, { asset, verbs }) looping previews: each verb as a small animated WebP, and a sheet
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ import { LIMITS, checkGlb } from '../assets/safety.mjs';
27
+ import { readManifest, recordAsset } from './asset-manifest.mjs';
28
+ import { VERBS, VERB_IDS, bakeLibrary, findClip, rigOf, writeLibrary } from './clips.mjs';
29
+ import { readDecisions } from './decisions.mjs';
30
+ import { FAMILIES, normaliseRig, skeletonOf, socketBone } from './rig.mjs';
31
+ import { GAME_ID } from './studio.mjs';
32
+
33
+ /** Where humanoid clips come from when a character has none of its own: KayKit's adventurers (CC0, 76 clips). */
34
+ export const CLIP_SOURCES = Object.freeze({
35
+ humanoid: 'kaykit-adventurers/knight',
36
+ mini: 'kaykit-adventurers/knight',
37
+ });
38
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
39
+ const kb = (n) => Math.round(n / 1024);
40
+
41
+ /** The game's verbs: its anim.clips decision (words mapped to VERBS), always idle, plus any asked for. */
42
+ export function verbsFor(doc, extra = []) {
43
+ const want = [...(doc?.decisions?.['anim.clips']?.value ?? ['idle', 'run', 'jump', 'attack', 'hit']), ...extra];
44
+ const alias = { 'pick-up': 'pickup', pick: 'pickup', kick: 'attack2', aim: 'shoot', boost: 'run', crash: 'hit', walk: 'walk', swing: 'attack', punch: 'attack', bump: 'attack', cheer: 'emote', wave: 'emote', dance: 'emote', victory: 'win' };
45
+ const out = new Set(['idle']);
46
+ for (const w of want) { const v = alias[w] ?? w; if (VERB_IDS.includes(v)) out.add(v); }
47
+ return [...out];
48
+ }
49
+
50
+ /** The model tools, once. */
51
+ async function tools() { const { modelTools } = await import('./optimise.mjs'); return modelTools(); }
52
+
53
+ /** A Document from bytes (or a path resolving its sibling pictures). */
54
+ async function docOf(input) {
55
+ const t = await tools();
56
+ const { selfContained } = await import('./optimise.mjs');
57
+ const bytes = await selfContained(input, LIMITS.import);
58
+ const safe = checkGlb(bytes, LIMITS.import);
59
+ if (!safe.ok) throw new Error(`refused: ${safe.problems.slice(0, 3).join('; ')}`);
60
+ const doc = await t.io.readBinary(bytes);
61
+ doc.setLogger(t.logger);
62
+ return { doc, bytes };
63
+ }
64
+
65
+ /** A clip source (a library item id, or a file): its Document renamed to the standard, and its clip names. */
66
+ async function clipSource(from, { lib = null, cache = new Map() } = {}) {
67
+ if (cache.has(from)) return cache.get(from);
68
+ let bytes;
69
+ if (existsSync(resolve(String(from)))) bytes = readFileSync(resolve(String(from)));
70
+ else {
71
+ const { fetchItemFile, libraryBase, loadIndex } = await import('./library.mjs');
72
+ const L = lib ?? libraryBase();
73
+ const { index } = await loadIndex({ lib: L });
74
+ const item = index.items.find((x) => x.id === from);
75
+ if (!item) throw new Error(`no library item "${from}" to take clips from`);
76
+ if (item.license !== 'cc0') throw new Error(`${from} is not CC0`);
77
+ ({ bytes } = await fetchItemFile(L, item));
78
+ }
79
+ const { doc } = await docOf(bytes);
80
+ normaliseRig(doc, { merge: false, helpers: false, takeClips: false });
81
+ const src = { from: String(from), doc, rig: rigOf(doc), names: doc.getRoot().listAnimations().map((a) => a.getName()) };
82
+ cache.set(from, src);
83
+ return src;
84
+ }
85
+
86
+ /**
87
+ * The clips for `verbs` on `target` (a rig): the character's own where it has one, else from `fallback` (retargeted).
88
+ * Returns { sources: [{ rig, anim, verb, from }], missing: [verb] }.
89
+ */
90
+ async function pickSources(own, verbs, fallback, opts) {
91
+ const sources = []; const missing = [];
92
+ let fb = null;
93
+ for (const verb of verbs) {
94
+ const mine = own ? findClip(own.names, verb) : null;
95
+ if (mine) { sources.push({ rig: own.rig, anim: own.doc.getRoot().listAnimations().find((a) => a.getName() === mine), verb, from: own.from }); continue; }
96
+ if (fallback && !fb) { try { fb = await clipSource(fallback, opts); } catch (error) { fb = { error: error.message }; } }
97
+ const theirs = fb && !fb.error ? findClip(fb.names, verb) : null;
98
+ if (theirs) sources.push({ rig: fb.rig, anim: fb.doc.getRoot().listAnimations().find((a) => a.getName() === theirs), verb, from: fb.from });
99
+ else missing.push(verb);
100
+ }
101
+ return { sources, missing, fallbackError: fb?.error ?? null };
102
+ }
103
+
104
+ /** A clip library file's path in a game, for a skeleton. */
105
+ export const animsPath = (skeleton) => `public/anims/${skeleton}.glb`;
106
+
107
+ /** Read a clip library already in the game (its verbs), or null. */
108
+ async function existingLibrary(gdir, skeleton) {
109
+ const file = join(gdir, animsPath(skeleton));
110
+ if (!existsSync(file)) return null;
111
+ const t = await tools();
112
+ const doc = await t.io.readBinary(readFileSync(file));
113
+ doc.setLogger(t.logger);
114
+ return { doc, verbs: doc.getRoot().listAnimations().map((a) => a.getName()) };
115
+ }
116
+
117
+ /** Copy the animations of `from` (a baked library Document) into `into` (an existing one), by node name. */
118
+ function appendClips(into, from) {
119
+ const root = into.getRoot();
120
+ const buffer = root.listBuffers()[0] ?? into.createBuffer();
121
+ const nodes = new Map(root.listNodes().map((n) => [n.getName(), n]));
122
+ for (const a of from.getRoot().listAnimations()) {
123
+ for (const old of root.listAnimations().filter((x) => x.getName() === a.getName())) old.dispose();
124
+ const b = into.createAnimation(a.getName()).setExtras(a.getExtras());
125
+ for (const ch of a.listChannels()) {
126
+ const node = nodes.get(ch.getTargetNode()?.getName());
127
+ if (!node) continue;
128
+ const s = ch.getSampler();
129
+ const sampler = into.createAnimationSampler()
130
+ .setInput(into.createAccessor().setType('SCALAR').setArray(s.getInput().getArray().slice()).setBuffer(buffer))
131
+ .setOutput(into.createAccessor().setType(s.getOutput().getType()).setArray(s.getOutput().getArray().slice()).setBuffer(buffer))
132
+ .setInterpolation(s.getInterpolation());
133
+ b.addSampler(sampler).addChannel(into.createAnimationChannel().setTargetNode(node).setTargetPath(ch.getTargetPath()).setSampler(sampler));
134
+ }
135
+ }
136
+ const ex = root.getExtras()?.homie ?? {};
137
+ root.setExtras({ homie: { ...ex, verbs: root.listAnimations().map((x) => x.getName()) } });
138
+ }
139
+
140
+ /**
141
+ * Bake (or extend) a skeleton's clip library in a game and record it. `rig`: the normalised character's rig;
142
+ * `own`: its own clips (a clip source) or null. Returns { path, skeleton, verbs, clips, missing, bytes, entry }.
143
+ */
144
+ async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, cache = new Map(), license = null } = {}) {
145
+ const gdir = join(root, 'games', game);
146
+ const skel = rig.skeleton;
147
+ const fallback = from ?? CLIP_SOURCES[skel.family] ?? null;
148
+ const existing = await existingLibrary(gdir, skel.id);
149
+ const todo = verbs.filter((v) => !existing?.verbs.includes(v));
150
+ const { sources, missing, fallbackError } = await pickSources(own, todo, fallback, { lib, cache });
151
+ const t = await tools();
152
+ const rel = animsPath(skel.id);
153
+ const manifest = readManifest(root, game);
154
+ const entryId = `anims-${skel.id}`.slice(0, 48);
155
+ const prev = manifest.assets.find((a) => a.id === entryId);
156
+ let doc; let clips;
157
+ if (sources.length || !existing) {
158
+ ({ doc, clips } = await bakeLibrary(rig, sources, { core: t.core, logger: t.logger }));
159
+ if (existing) { appendClips(existing.doc, doc); doc = existing.doc; }
160
+ } else { doc = existing.doc; clips = []; }
161
+ if (!sources.length && existing) return { path: rel, skeleton: skel.id, verbs: existing.verbs, clips: prev?.clips ?? [], missing, bytes: null, entry: prev ?? null, fallbackError };
162
+ const out = await writeLibrary(doc, { tools: t });
163
+ mkdirSync(dirname(join(gdir, rel)), { recursive: true });
164
+ writeFileSync(join(gdir, rel), out.bytes);
165
+ const allClips = [...(prev?.clips ?? []).filter((c) => !clips.some((n) => n.verb === c.verb)), ...clips].sort((a, b) => VERB_IDS.indexOf(a.verb) - VERB_IDS.indexOf(b.verb));
166
+ const froms = [...new Set(allClips.map((c) => c.from).filter(Boolean))];
167
+ // Clips taken from CC0 sources (KayKit, Kenney) stay CC0 when retargeted; a character's own clips carry its licence.
168
+ const lic = license && license.kind !== 'cc0' && allClips.some((c) => c.from && !String(c.from).includes('/')) ? license : { kind: 'cc0', spdx: 'CC0-1.0', owner: null, attribution: null, remix: 'include', notes: `Clips from ${froms.join(', ') || 'the character itself'} (CC0), ${allClips.some((c) => c.retargeted) ? 'retargeted onto this skeleton at build time' : 'as they came'}.` };
169
+ const entry = {
170
+ id: entryId, kind: 'clip', tier: 'clip', route: allClips.every((c) => !c.from || String(c.from).includes('/')) ? 'library' : 'imported',
171
+ card: null,
172
+ from: froms.length ? { library: 'homie-starter', items: froms } : null,
173
+ files: [{ role: 'clip', path: rel, bytes: out.bytes.length, sha256: sha256(out.bytes) }],
174
+ rig: { family: skel.family, skeleton: skel.id, fingerprint: skel.fingerprint },
175
+ clips: allClips,
176
+ measured: { clips: out.clips, keys: out.keys, kb: kb(out.bytes.length), fps: 30, verbs: allClips.map((c) => c.verb) },
177
+ license: lic,
178
+ made: { steps: [{ what: 'clips', tool: 'lib/clips.mjs (retarget and bake)', verbs: allClips.map((c) => `${c.verb}<${c.source}${c.retargeted ? ' (retargeted)' : ''}`) }] },
179
+ review: { state: 'auto' },
180
+ };
181
+ const { entry: e } = recordAsset(root, game, entry);
182
+ return { path: rel, skeleton: skel.id, verbs: allClips.map((c) => c.verb), clips: allClips, missing, bytes: out.bytes.length, entry: e, fallbackError };
183
+ }
184
+
185
+ /**
186
+ * One rigged (or node-animated) character into a game. Options:
187
+ * item a starter-library item id (kaykit-adventurers/knight), or
188
+ * file a .glb on this computer, with license (own, cc0, cc-by-4.0, generated, ...)
189
+ * as the asset id; kind character (default) or creature; card ("Characters/Knight")
190
+ * height metres to scale it to (default: the game's cast.scale character height, else the item's suggestion)
191
+ * keep the held things to keep (a sword, a shield, a hat: names; part swaps); null keeps them all
192
+ * verbs verbs to bake besides the game's anim.clips
193
+ * clipsFrom where clips it lacks come from (a library item or a file); default by its skeleton family
194
+ * texture largest picture side (default 512: a character is a hundred pixels tall on a phone)
195
+ * triangles budget (default: the tier's, a hero 8,000)
196
+ * route, steps, raw, concept, owner, attribution, notes: for a generated or imported file (as importModel takes)
197
+ * Returns { ok, asset, model, anims, skeleton, verbs, missing, before, after, merged, dropped, removedHelpers }.
198
+ */
199
+ export async function addCharacter(root, game, opts = {}) {
200
+ if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
201
+ const gdir = join(root, 'games', game);
202
+ if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${game}"`);
203
+ const t = await tools();
204
+ const { optimiseModel, measureDoc } = await import('./optimise.mjs');
205
+ const decisions = readDecisions(root, game);
206
+ let bytes; let item = null; let idx = null; let lib = opts.lib ?? null;
207
+ if (opts.item) {
208
+ const L = await import('./library.mjs');
209
+ lib = lib ?? L.libraryBase();
210
+ idx = opts.index ?? (await L.loadIndex({ lib })).index;
211
+ item = idx.items.find((x) => x.id === opts.item) ?? idx.items.find((x) => x.item === opts.item);
212
+ if (!item) throw new Error(`no library item "${opts.item}" (assets find "<words>" --kind character lists them)`);
213
+ if (item.license !== 'cc0') throw new Error(`${item.id} is not CC0; the starter library holds only CC0`);
214
+ ({ bytes } = await L.fetchItemFile(lib, item));
215
+ } else if (opts.file) {
216
+ const { screenImport } = await import('./asset-import.mjs');
217
+ screenImport(resolve(root, String(opts.file)));
218
+ bytes = (await docOf(resolve(root, String(opts.file)))).bytes;
219
+ } else throw new Error('a library item or a file');
220
+ const id = String(opts.as ?? item?.item ?? 'character').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
221
+ const kind = opts.kind === 'creature' || item?.kind === 'creature' ? 'creature' : 'character';
222
+ const cache = new Map();
223
+
224
+ // Its own clips, renamed to the standard (a separate copy: the model itself loses them).
225
+ const ownDoc = (await docOf(bytes)).doc;
226
+ const raw = skeletonOf(ownDoc);
227
+ if (raw.family === 'none') throw new Error(`${item?.id ?? opts.file} has no skeleton and no moving parts: add it as a prop (assets add without a rig), or rig it first (the animate guide)`);
228
+ normaliseRig(ownDoc, { merge: false, helpers: false, takeClips: false });
229
+ const own = ownDoc.getRoot().listAnimations().length ? { from: item?.id ?? 'own', doc: ownDoc, rig: rigOf(ownDoc), names: ownDoc.getRoot().listAnimations().map((a) => a.getName()) } : null;
230
+
231
+ // The character as shipped: renamed, helpers out, merged, clips out.
232
+ const { doc } = await docOf(bytes);
233
+ const rigged = normaliseRig(doc, { keep: opts.keep ?? null, takeClips: true });
234
+ const rig = rigOf(doc);
235
+ const verbs = verbsFor(decisions, opts.verbs ?? []);
236
+ const anims = await bakeFor(root, game, rig, own, verbs, { from: opts.clipsFrom ?? null, lib, cache, license: opts.license ? { kind: opts.license } : null });
237
+
238
+ // The model: phone-sized, its clip library named in its scene's extras.
239
+ const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0];
240
+ scene.setExtras({ ...(scene.getExtras() ?? {}), homie: { kind, skeleton: rig.skeleton.id, family: rig.skeleton.family, anims: `../anims/${rig.skeleton.id}.glb`, sockets: Object.fromEntries(['rightHand', 'leftHand', 'head', 'back'].map((s) => [s, socketBone(rig.nodes.map((n) => n.name), s)]).filter(([, b]) => b)) } });
241
+ const normalised = await t.io.writeBinary(doc);
242
+ const castH = Number(decisions?.decisions?.['cast.scale']?.value?.characterHeightM ?? 0);
243
+ const height = Number(opts.height ?? 0) || castH || Number(item?.suggestM ?? 0) || null;
244
+ const tri = Number(opts.triangles ?? 0) || (kind === 'creature' ? 3000 : 8000);
245
+ const r = await optimiseModel(normalised, { height, triangles: tri, texture: Number(opts.texture ?? 512), rigged: true, metal: false });
246
+ const rel = `public/models/${id}.glb`;
247
+ mkdirSync(dirname(join(gdir, rel)), { recursive: true });
248
+ writeFileSync(join(gdir, rel), r.glb);
249
+ const after = r.after;
250
+ const files = [{ role: 'model', path: rel, bytes: r.bytes, sha256: r.sha256 }];
251
+ if (opts.rawPath) files.push({ role: 'raw', path: opts.rawPath, public: false });
252
+ if (opts.concept) files.push({ role: 'concept', path: String(opts.concept).startsWith('../') ? String(opts.concept) : `../../${String(opts.concept).replace(/^\.\//, '')}`, public: false });
253
+ const steps = [
254
+ ...(item ? [{ what: 'library', item: item.id, sha256: item.files?.[0]?.sha256 ?? null }] : []),
255
+ ...(opts.steps ?? []),
256
+ { what: 'rig', tool: 'lib/rig.mjs', family: rig.skeleton.family, skeleton: rig.skeleton.id, renamed: rigged.renamed, removedHelpers: rigged.removedHelpers.length, merged: rigged.merged, dropped: rigged.dropped },
257
+ { what: 'clips', anims: anims.path, verbs: anims.verbs },
258
+ { what: 'optimise', tool: r.tool, ops: r.ops },
259
+ ];
260
+ const license = item
261
+ ? { kind: 'cc0', spdx: 'CC0-1.0', owner: item.author ?? null, attribution: null, remix: 'include', notes: `From the Homie starter library (${item.pack}); the pack's own licence text: ${idx?.packs?.find((p) => p.id === item.pack)?.licenseFile ?? 'licenses/'}` }
262
+ : { kind: String(opts.license ?? ''), owner: opts.owner ?? (opts.license === 'generated' || opts.license === 'own' ? 'studio' : null), attribution: opts.attribution ?? null, notes: opts.notes ?? (opts.license === 'generated' ? 'Made through fal on the studio\'s own account (a concept image, then Meshy image-to-3D with its auto-rig); its clips are the starter library\'s CC0 clips retargeted onto its skeleton. fal\'s terms do not assign output ownership expressly (RIGHTS.md).' : null) };
263
+ const entry = {
264
+ id, kind, tier: kind === 'creature' ? 'npc' : 'hero', card: opts.card ?? null, route: item ? 'library' : (opts.route === 'generated' ? 'generated' : 'imported'),
265
+ ...(item ? { from: { library: 'homie-starter', version: idx?.version ?? 'v0', pack: item.pack, packLabel: idx?.packs?.find((p) => p.id === item.pack)?.label ?? item.pack, packUrl: idx?.packs?.find((p) => p.id === item.pack)?.url ?? null, item: item.id, author: item.author ?? null, origin: item.origin ?? null, sha256: item.files?.[0]?.sha256 ?? null, paletteSwap: Boolean(item.paletteSwap) } } : {}),
266
+ files,
267
+ rig: { family: rig.skeleton.family, skeleton: rig.skeleton.id, fingerprint: rig.skeleton.fingerprint, skinned: rig.skeleton.skinned, bones: after.bones || rig.skeleton.bones, deform: rig.skeleton.deform, influences: rig.skeleton.influences, anims: anims.path, verbs: anims.verbs, keep: rigged.kept, dropped: rigged.dropped },
268
+ measured: { tris: after.triangles, materials: after.materials, drawCalls: after.drawCalls, textures: after.textures.map((x) => ({ px: x.px, format: x.mimeType, gpuKB: Math.round(x.gpuBytes / 1024) })), bones: after.bones, clips: anims.verbs.length, heightM: after.heightM, box: after.box, glbKB: kb(r.bytes), animsKB: anims.bytes ? kb(anims.bytes) : null },
269
+ license,
270
+ made: { steps },
271
+ review: { state: 'auto' },
272
+ };
273
+ const { entry: e, pinned } = recordAsset(root, game, entry);
274
+ return { ok: true, command: 'assets add', game, asset: e.id, item: item?.id ?? null, route: e.route, raw: opts.rawPath ?? null, license: e.license.kind, model: rel, anims: anims.path, skeleton: rig.skeleton.id, family: rig.skeleton.family, verbs: anims.verbs, missing: anims.missing, clipsFrom: anims.clips.filter((c) => c.retargeted).map((c) => c.from).filter((v, i, a) => a.indexOf(v) === i), merged: rigged.merged, dropped: rigged.dropped, removedHelpers: rigged.removedHelpers.length, before: { tris: r.before.triangles, kb: kb(r.rawBytes), drawCalls: r.before.drawCalls }, after: { tris: after.triangles, kb: kb(r.bytes), heightM: after.heightM, drawCalls: after.drawCalls, bones: after.bones, animsKB: anims.bytes ? kb(anims.bytes) : null }, pinned, warnings: r.warnings, fallbackError: anims.fallbackError };
275
+ }
276
+
277
+ /** Whether a library item is a character with a rig or moving parts (assets add sends those here). */
278
+ export const isAnimated = (item) => Boolean(item && (item.rigged || (item.clips ?? []).length >= 1) && ['character', 'creature'].includes(item.kind));
279
+
280
+ /**
281
+ * More verbs for a character already in the game: baked into its skeleton's clip library, retargeted from `from`
282
+ * (a library item or a file; default: its family's clip source). Returns the bake's result.
283
+ */
284
+ export async function bakeClips(root, game, asset, { verbs = [], from = null, lib = null } = {}) {
285
+ const m = readManifest(root, game);
286
+ const a = m.assets.find((x) => x.id === asset);
287
+ if (!a?.rig) throw new Error(`${asset} is not a rigged character in ${game} (assets add brings one in)`);
288
+ const t = await tools();
289
+ const model = a.files.find((f) => f.role === 'model');
290
+ const doc = await t.io.readBinary(readFileSync(join(root, 'games', game, model.path)));
291
+ doc.setLogger(t.logger);
292
+ const rig = rigOf(doc);
293
+ if (rig.skeleton.id !== a.rig.skeleton) rig.skeleton.id = a.rig.skeleton;
294
+ const want = verbs.filter((v) => VERB_IDS.includes(v));
295
+ if (!want.length) throw new Error(`which verbs? ${VERB_IDS.join(', ')}`);
296
+ let own = null;
297
+ if (a.from?.item && !from) { try { own = await clipSource(a.from.item, { lib }); } catch { own = null; } }
298
+ const r = await bakeFor(root, game, rig, own, want, { from, lib });
299
+ // The character's own record learns its verbs.
300
+ const fresh = readManifest(root, game);
301
+ const me = fresh.assets.find((x) => x.id === asset);
302
+ if (me?.rig) { me.rig.verbs = r.verbs; me.measured = { ...(me.measured ?? {}), clips: r.verbs.length }; const { writeManifest } = await import('./asset-manifest.mjs'); writeManifest(root, game, fresh); }
303
+ return { ok: true, command: 'anim add', game, asset, skeleton: r.skeleton, anims: r.path, verbs: r.verbs, added: want.filter((v) => r.verbs.includes(v)), missing: r.missing, kb: r.bytes ? kb(r.bytes) : null, why: r.fallbackError };
304
+ }
305
+
306
+ /** Each character, its skeleton and its clips against the verbs the game needs. */
307
+ export function animPlan(root, game) {
308
+ const m = readManifest(root, game);
309
+ const doc = readDecisions(root, game);
310
+ const need = verbsFor(doc);
311
+ const libs = new Map(m.assets.filter((a) => a.kind === 'clip').map((a) => [a.rig?.skeleton, a]));
312
+ const rows = m.assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && a.rig).map((a) => {
313
+ const L = libs.get(a.rig.skeleton);
314
+ const have = L?.clips?.map((c) => c.verb) ?? a.rig.verbs ?? [];
315
+ return {
316
+ id: a.id, kind: a.kind, route: a.route, family: a.rig.family, familyLabel: FAMILIES[a.rig.family]?.label ?? a.rig.family, skeleton: a.rig.skeleton, bones: a.rig.bones, anims: a.rig.anims,
317
+ clips: need.map((v) => { const c = L?.clips?.find((x) => x.verb === v); return { verb: v, note: VERBS[v]?.note ?? '', loop: Boolean(VERBS[v]?.loop), have: have.includes(v), source: c?.source ?? null, from: c?.from ?? null, retargeted: Boolean(c?.retargeted), seconds: c?.duration ?? null }; }),
318
+ extra: have.filter((v) => !need.includes(v)),
319
+ missing: need.filter((v) => !have.includes(v)),
320
+ animsKB: L?.measured?.kb ?? null,
321
+ };
322
+ });
323
+ const unrigged = m.assets.filter((a) => (a.kind === 'character' || a.kind === 'creature') && !a.rig).map((a) => a.id);
324
+ const d = doc?.decisions ?? {};
325
+ return {
326
+ ok: true, command: 'anim plan', game, verbs: need, rows, unrigged,
327
+ decisions: Object.fromEntries(['anim.style', 'anim.clips', 'anim.source', 'anim.motion', 'anim.blend', 'anim.procedural', 'rig.skeleton', 'rig.source', 'rig.bones', 'game.feel'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state } : null])),
328
+ lab: existsSync(join(root, 'games', game, 'lab.json')) ? JSON.parse(readFileSync(join(root, 'games', game, 'lab.json'), 'utf8')) : null,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Looping previews: each character's verbs as small animated WebP files (and one sheet a person can read), under the
334
+ * game's light, in .studio/art/<game>/anim/. Free; one headless Chrome. Returns { rows: [{ id, verbs: [{ verb, file,
335
+ * frames, seconds }], sheet }] }.
336
+ */
337
+ export async function animPreview(root, game, { asset = null, verbs = null, size = 144, count = 12, log = () => {} } = {}) {
338
+ const plan = animPlan(root, game);
339
+ const rows = plan.rows.filter((r) => !asset || r.id === asset);
340
+ if (!rows.length) return { ok: false, command: 'anim preview', game, why: asset ? `${asset} is not a rigged character in ${game}` : `${game} has no rigged characters yet (assets add <game> <character item>)` };
341
+ const m = readManifest(root, game);
342
+ const gdir = join(root, 'games', game);
343
+ let tokens = null;
344
+ try { tokens = JSON.parse(readFileSync(join(gdir, 'style.json'), 'utf8')); } catch { tokens = null; }
345
+ tokens = tokens?.palette ? tokens : { palette: { bg: '#e9eef2', ink: '#1d2b3a', accent: '#ff8a5b', accent2: '#5cbf8a', danger: '#e2553f', good: '#45b36b', gold: '#ffd25a' } };
346
+ const dir = join(root, '.studio', 'art', game, 'anim');
347
+ mkdirSync(dir, { recursive: true });
348
+ const { withRenderer, modelIn, dataUrlBytes } = await import('./render3d.mjs');
349
+ let sharp = null;
350
+ try { sharp = (await import('sharp')).default; } catch { sharp = null; }
351
+ const out = [];
352
+ await withRenderer(async (r) => {
353
+ for (const row of rows) {
354
+ const a = m.assets.find((x) => x.id === row.id);
355
+ const model = readFileSync(join(gdir, a.files.find((f) => f.role === 'model').path));
356
+ const animsFile = join(gdir, row.anims);
357
+ if (!existsSync(animsFile)) { out.push({ id: row.id, verbs: [], why: `${row.anims} is missing` }); continue; }
358
+ const want = verbs ?? row.clips.filter((c) => c.have).map((c) => c.verb).concat(row.extra);
359
+ const res = await r.frames(modelIn(row.id, model, { anims: readFileSync(animsFile).toString('base64') }), tokens, { verbs: want, count, size, sheet: true, loops: want.filter((v) => VERBS[v]?.loop) });
360
+ const files = [];
361
+ for (const v of res.verbs) {
362
+ const file = join(dir, `${row.id}-${v.verb}.webp`);
363
+ if (sharp) {
364
+ const frames = v.frames.map((f) => dataUrlBytes(f).bytes);
365
+ // One animated WebP a verb: the frames at the clip's own pace, looping.
366
+ const delay = Math.max(40, Math.round((v.duration * 1000) / Math.max(1, frames.length)));
367
+ await sharp(frames, { join: { animated: true } }).webp({ quality: 70, loop: 0, delay: frames.map(() => delay), effort: 4 }).toFile(file);
368
+ } else writeFileSync(file.replace(/\.webp$/, '.png'), dataUrlBytes(v.frames[0]).bytes);
369
+ files.push({ verb: v.verb, file: file.slice(root.length + 1), frames: v.frames.length, seconds: v.duration });
370
+ }
371
+ let sheet = null;
372
+ if (res.sheet) { sheet = join(dir, `${row.id}-sheet.jpg`); writeFileSync(sheet, dataUrlBytes(res.sheet).bytes); sheet = sheet.slice(root.length + 1); }
373
+ out.push({ id: row.id, verbs: files, sheet, missing: res.missing });
374
+ log(`${row.id}: ${files.length} preview(s)`);
375
+ }
376
+ }, { log });
377
+ const result = { ok: true, command: 'anim preview', game, rows: out, at: new Date().toISOString() };
378
+ writeFileSync(join(dir, 'previews.json'), `${JSON.stringify(result, null, 2)}\n`);
379
+ return result;
380
+ }