@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
@@ -219,6 +219,12 @@
219
219
  * glTF-Validator, licences, stale assets, big files in git
220
220
  * homie-studio assets lineup|review|rights|stale|remove <id> … the lineup (true scale, silhouettes, palette drift),
221
221
  * a reviewer's score, RIGHTS.md, what a changed decision made stale
222
+ * homie-studio cast <id> the characters: proportions, silhouette, palette, skeleton family, clips
223
+ * homie-studio anim [plan] <id> each character's clips against the verbs the game needs (idle, run, jump,
224
+ * attack, hit, die, ...) and where each comes from
225
+ * homie-studio anim add <id> <asset> --verbs jump,attack [--from <library item>] more verbs, retargeted onto its
226
+ * skeleton at build time into its clip library (public/anims/<skeleton>.glb)
227
+ * homie-studio anim preview <id> [--asset <asset>] looping previews of every clip (animated WebP) and a sheet
222
228
  *
223
229
  * homie-studio statusline the current build in one line (what Claude Code's status line shows)
224
230
  * homie-studio statusline --install [--project <folder>]
@@ -266,12 +272,12 @@ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
266
272
  import { restartWithProxy } from '../lib/net.mjs';
267
273
  import { demoGames, formatDemo } from '../lib/demo.mjs';
268
274
  import { serveMcp } from '../lib/mcp.mjs';
269
- import { artLines, assetsCommand, styleCommand } from '../lib/art-cli.mjs';
275
+ import { animCommand, artLines, assetsCommand, castView, styleCommand } from '../lib/art-cli.mjs';
270
276
  import { formatHandoff, handoff } from '../lib/handoff.mjs';
271
277
 
272
278
  const argv = process.argv.slice(2);
273
279
  const flags = new Map();
274
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
280
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
275
281
  const positional = [];
276
282
  for (let i = 0; i < argv.length; i++) {
277
283
  const a = argv[i];
@@ -287,10 +293,11 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
287
293
 
288
294
  function print(result) {
289
295
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
290
- if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
296
+ // A check that found problems still prints them (its rows say what to fix), never a bare "failed".
297
+ if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows) && !(result.command === 'assets check' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
291
298
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
292
299
  const lines = [];
293
- if (/^(style|assets)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
300
+ if (/^(style|assets|cast|anim)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
294
301
  switch (result.command) {
295
302
  case 'new':
296
303
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
@@ -686,6 +693,8 @@ async function main() {
686
693
  const root = requireStudio();
687
694
  if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
688
695
  if (cmd === 'assets') return assetsCommand(root, sub, positional, flags, { log });
696
+ if (cmd === 'anim') return animCommand(root, sub, positional, flags, { log });
697
+ if (cmd === 'cast') { const games = listGames(root); const id = sub ?? (games.length === 1 ? games[0].id : null); if (!id) return { ok: false, command: 'cast', why: `name the game: homie-studio cast <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}` }; return castView(root, id); }
689
698
  if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
690
699
  if (cmd === 'codex') return codexCommand(root, sub);
691
700
  if (cmd === 'progress') return progressCommand(root, sub);
package/chat/CHAT.md CHANGED
@@ -14,9 +14,12 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
14
14
 
15
15
  - **The Chat pill** on the play page, beside the room button: the room's last minutes, the
16
16
  reactions, the quick lines, typing where allowed, Report on a line, and two switches ("Show my
17
- messages over my character", "Show chat on this screen").
17
+ messages over my character", "Show chat on this screen"). On a phone it is a round icon, as the
18
+ room button is, with the count of new lines on its corner; on a computer it says "Chat" unless
19
+ the word would reach the middle of the top edge, where games keep a clock or a title.
18
20
  - **The float:** every reaction rises up every screen in the room.
19
- - **The ticker:** a new line shows for a moment where the play page's status chip sits.
21
+ - **The strip:** a new line shows for a moment where the play page's status chip sits (the bottom
22
+ left), or where the game says (below).
20
23
  - **The big screen** (`/<game>/tv`) shows the room's lines in a corner; the **watch page** has a
21
24
  Chat button; **homie.rocks** shows the room's chat on its card when the studio allows it.
22
25
 
@@ -49,9 +52,37 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
49
52
  | `block`, `allow` | the studio's own words to hold (`word*` inside words) or to let through | none |
50
53
 
51
54
  `"chat": false` turns it off. A kids server, and every beginner server, keeps chat to emoji and
52
- quick lines whatever the game says. Where new lines show for a moment: game.json
53
- `"screen": { "chat": "bottom-right" }` (any corner, `top-center` or `bottom-center`), or `false`
54
- for only the pill's count.
55
+ quick lines whatever the game says.
56
+
57
+ **Where it sits on the screen, in game.json `"screen"`**, so a game with a busy HUD keeps typed
58
+ chat without anything covering its clock, its title or its controls. The Chat pill sits in the
59
+ room button's band, so `"screen": { "share": … }` places both (`"label": false` keeps both round
60
+ icons). `"screen": { "chat": … }` places the strip of new lines and the big screen's corner:
61
+
62
+ ```json
63
+ "screen": {
64
+ "share": { "phone": { "at": "top-left", "label": false } },
65
+ "chat": {
66
+ "desk": { "at": "top-left", "y": 130 },
67
+ "phone": { "lines": "sheet-only" },
68
+ "tv": { "at": "top-right" }
69
+ }
70
+ }
71
+ ```
72
+
73
+ | Field | | Default |
74
+ |---|---|---|
75
+ | `at` | `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center` or `bottom-right` | the bottom left (the bottom right when the room button is at the bottom left) |
76
+ | `x`, `y` | move it in from its side and its edge, in CSS pixels (0 to 600; in the middle of an edge, `x` moves it either way). Sharing the room button's place, it also sits past the button | 0 |
77
+ | `lines` | `strip` (new lines show for a moment), or `sheet-only` (they wait in the Chat sheet and the pill counts them) | `strip` |
78
+ | `desk`, `phone`, `sideways`, `tv` | a computer, a phone held upright, a phone turned sideways (else as `phone`), the big screen: each changes only what it names | |
79
+
80
+ A plain string (`"chat": "bottom-right"`) is a place for every device, and `false` is
81
+ `"lines": "sheet-only"` for every device. The big screen has no sheet: it takes a corner only
82
+ (the top level's when that is a corner, else the one its join card leaves free) and keeps its
83
+ lines unless its own `tv` entry says `"sheet-only"`, which leaves it the float. A wrong field is
84
+ the default on the page, and `homie-studio build` says which. Look at the play page on a phone
85
+ and a computer while a round is on and somebody says something, and at `/<id>/tv`.
55
86
 
56
87
  **Speech bubbles over characters**, with the port kit (the starters draw them):
57
88
 
package/lib/art-cli.mjs CHANGED
@@ -29,6 +29,14 @@
29
29
  * assets stale <id> assets made under an older decision
30
30
  * assets redo <id> <asset> made again from its kept raw file (free): today's budgets and render style
31
31
  * assets remove <id> <asset>
32
+ * assets add <id> <character item> [--keep "1H_Sword,Round_Shield"] [--verbs jump,attack] [--clips-from <item|file>]
33
+ * an animated library character (a rig, or parts with clips): the character pipeline (lib/characters.mjs)
34
+ * assets add <id> --file <rigged.glb> --kind character --rigged --license <kind> [--clips-from <item>]
35
+ *
36
+ * cast <id> the characters: proportions, silhouette, palette, skeleton family, source, clips
37
+ * anim [plan] <id> each character's clips against the verbs the game needs, and where each comes from
38
+ * anim add <id> <asset> --verbs jump,attack [--from <library item | file>] more verbs, retargeted onto its skeleton
39
+ * anim preview <id> [--asset <asset>] [--verbs idle,run] looping previews (animated WebP) and a sheet, free
32
40
  */
33
41
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
42
  import { join, resolve } from 'node:path';
@@ -36,6 +44,7 @@ import { licenceProblems, readManifest, removeAsset, rightsMarkdown, syncCredits
36
44
  import { CATALOGUE_IDS, PHASES, blastRadius, decisionRows, derivedPrompt, initDecisions, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
37
45
  import { listGames } from './studio.mjs';
38
46
  import { checkLines } from './asset-check.mjs';
47
+ import { animPlan as animPlanOf } from './characters.mjs';
39
48
 
40
49
  /** The game a command means: the one named, else the only one. */
41
50
  function gameOf(root, id, command) {
@@ -91,6 +100,13 @@ export function writeArtSummary(root, id) {
91
100
  spend: artSpend(root, id),
92
101
  check: check ? { ok: check.ok, at: check.at, totals: check.totals, budgets: { drawCalls: check.budgets?.drawCalls, triangles: check.budgets?.triangles, textureMB: check.budgets?.textureMB, firstPlayMB: check.budgets?.firstPlayMB }, failing: check.rows.filter((r) => !r.ok).map((r) => r.id) } : null,
93
102
  lineup: lineup ? { at: lineup.at, flagged: lineup.flagged, images: lineup.images } : null,
103
+ // The characters and their clips (lib/characters.mjs animPlan): the Art tab's Characters, /cast and /clips.
104
+ ...(() => {
105
+ try {
106
+ const plan = animPlanOf(root, id);
107
+ return { need: plan.verbs, characters: plan.rows.map((r) => ({ id: r.id, kind: r.kind, route: r.route, family: r.family, skeleton: r.skeleton, bones: r.bones, verbs: [...r.clips.filter((c) => c.have).map((c) => c.verb), ...r.extra], missing: r.missing, retargeted: r.clips.filter((c) => c.retargeted).length, animsKB: r.animsKB })), skinning: check?.skinning ? { players: check.skinning.players, vertices: check.skinning.vertices, bones: check.skinning.bones, budget: check.skinning.budget } : null };
108
+ } catch { return { need: [], characters: [], skinning: null }; }
109
+ })(),
94
110
  board: doc?.board ? { chosen: doc.board.chosen, directions: doc.board.directions.map((d) => ({ id: d.id, label: d.label, swatch: d.swatch, mood: d.mood?.path ?? null })) } : null,
95
111
  };
96
112
  writeFileSync(join(dir, 'latest.json'), `${JSON.stringify(summary, null, 2)}\n`);
@@ -100,6 +116,18 @@ export function writeArtSummary(root, id) {
100
116
 
101
117
  const list = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
102
118
 
119
+ /** A raw character file kept beside its art job (art/<slug>/raw/, git-ignored), as importModel keeps a prop's. */
120
+ async function keepRaw(root, file, slug) {
121
+ const { copyFileSync } = await import('node:fs');
122
+ const { basename } = await import('node:path');
123
+ const src = resolve(root, file);
124
+ const safe = String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'character';
125
+ const dir = join(root, 'art', safe, 'raw');
126
+ const to = join(dir, basename(src));
127
+ if (src !== to) { mkdirSync(dir, { recursive: true }); copyFileSync(src, to); }
128
+ return `../../art/${safe}/raw/${basename(src)}`;
129
+ }
130
+
103
131
  export async function styleCommand(root, sub, positional, flags, { log = () => {} } = {}) {
104
132
  const known = ['init', 'show', 'set', 'steer', 'lock', 'unlock', 'board', 'pick', 'mood', 'golden', 'blast', 'prompt'];
105
133
  const verb = known.includes(sub) ? sub : 'show';
@@ -165,15 +193,30 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
165
193
  const doc = readDecisions(root, id);
166
194
  r = { ok: true, command: 'assets', id, assets: m.assets.map((a) => ({ id: a.id, kind: a.kind, route: a.route, tier: a.tier ?? null, card: a.card ?? null, license: a.license?.kind ?? null, remix: a.license?.remix ?? null, file: (a.files ?? []).find((f) => f.role === 'model')?.path ?? null, tris: a.measured?.tris ?? null, kb: a.measured?.glbKB ?? null, usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3) })), stale: doc ? staleAssets(doc, m).map((s) => s.id) : [], spend: artSpend(root, id) };
167
195
  } else if (verb === 'add') {
168
- if (flags.get('file')) {
196
+ const rigFlags = { keep: flags.has('keep') ? list(flags.get('keep')) : null, verbs: list(flags.get('verbs')), clipsFrom: flags.get('clips-from') ?? null, texture: flags.get('texture') ?? null, triangles: flags.get('triangles') ?? null };
197
+ const characterKind = ['character', 'creature'].includes(String(flags.get('kind') ?? ''));
198
+ if (flags.get('file') && (flags.has('rigged') && characterKind) && !flags.has('no-rig')) {
199
+ // A rigged character file (the person's own, or a provider's): the character pipeline, clips and all.
200
+ const { addCharacter } = await import('./characters.mjs');
201
+ let steps = [];
202
+ if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
203
+ const raw = await keepRaw(root, String(flags.get('file')), flags.get('slug') ?? flags.get('as') ?? 'character');
204
+ r = await addCharacter(root, id, { file: String(flags.get('file')), as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind'), card: flags.get('card') ?? null, height: flags.get('height') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, owner: flags.get('owner') ?? null, notes: flags.get('notes') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, rawPath: raw, ...rigFlags });
205
+ } else if (flags.get('file')) {
169
206
  const { importModel } = await import('./asset-import.mjs');
170
207
  let steps = [];
171
208
  if (flags.get('steps')) steps = JSON.parse(readFileSync(resolve(String(flags.get('steps'))), 'utf8'));
172
209
  r = await importModel(root, id, String(flags.get('file')), { as: flags.get('as') ?? args[1] ?? null, kind: flags.get('kind') ?? 'prop', tier: flags.get('tier') ?? null, card: flags.get('card') ?? null, height: flags.get('height') ?? null, triangles: flags.get('triangles') ?? null, texture: flags.get('texture') ?? null, license: flags.get('license') ?? null, attribution: flags.get('attribution') ?? null, notes: flags.get('notes') ?? null, owner: flags.get('owner') ?? null, route: flags.get('route') ?? 'imported', steps, concept: flags.get('concept') ?? null, slug: flags.get('slug') ?? null, rigged: flags.has('rigged') });
173
210
  } else {
174
211
  if (!args[1]) return { ok: false, command: 'assets add', why: 'usage: homie-studio assets add <game> <library item> (assets find "<words>" lists them), or --file <model> --license <kind>' };
175
- const { addFromLibrary, libraryBase } = await import('./library.mjs');
176
- r = await addFromLibrary(root, id, args[1], { as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, lib: libraryBase(flags.get('library') ?? undefined) });
212
+ const { addFromLibrary, libraryBase, loadIndex } = await import('./library.mjs');
213
+ const lib = libraryBase(flags.get('library') ?? undefined);
214
+ const { index } = await loadIndex({ lib });
215
+ const item = index.items.find((x) => x.id === args[1]) ?? index.items.find((x) => x.item === args[1]);
216
+ const { addCharacter, isAnimated } = await import('./characters.mjs');
217
+ // An animated character (a rig, or parts with clips): phone-sized with its clips in its skeleton's clip library.
218
+ if (isAnimated(item) && !flags.has('no-rig')) r = await addCharacter(root, id, { item: item.id, as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, kind: flags.get('kind') ?? null, lib, index, ...rigFlags });
219
+ else r = await addFromLibrary(root, id, args[1], { as: flags.get('as') ?? null, height: flags.get('height') ?? null, card: flags.get('card') ?? null, lib, index });
177
220
  }
178
221
  } else if (verb === 'redo') {
179
222
  if (!args[1]) return { ok: false, command: 'assets redo', why: 'usage: homie-studio assets redo <game> <asset> (made again from its kept raw file, free)' };
@@ -189,6 +232,44 @@ export async function assetsCommand(root, sub, positional, flags, { log = () =>
189
232
  return r;
190
233
  }
191
234
 
235
+ /** `cast <id>`: the game's characters and the decisions that shape them (a cast list for the cast card and the mod). */
236
+ export function castView(root, id) {
237
+ const doc = readDecisions(root, id);
238
+ const m = readManifest(root, id);
239
+ const d = doc?.decisions ?? {};
240
+ const pal = d['style.palette']?.value ?? null;
241
+ const planned = (d['cast.list']?.value ?? []).filter((c) => c.kind === 'character' || c.kind === 'creature');
242
+ const made = m.assets.filter((a) => a.kind === 'character' || a.kind === 'creature');
243
+ const rows = [
244
+ ...made.map((a) => ({ id: a.id, card: a.card ?? null, kind: a.kind, state: 'made', route: a.route, source: a.from?.item ?? (a.route === 'generated' ? 'generated' : a.license?.kind ?? null), family: a.rig?.family ?? null, skeleton: a.rig?.skeleton ?? null, bones: a.rig?.bones ?? a.measured?.bones ?? null, tris: a.measured?.tris ?? null, heightM: a.measured?.heightM ?? null, kb: a.measured?.glbKB ?? null, verbs: a.rig?.verbs ?? [], keep: a.rig?.keep ?? [], usd: +(a.made?.steps ?? []).reduce((n, s) => n + (Number(s.usd) || 0), 0).toFixed(3), license: a.license?.kind ?? null })),
245
+ ...planned.filter((c) => !made.some((a) => a.id === c.id || a.card === c.card)).map((c) => ({ id: c.id, card: c.card, kind: c.kind, state: 'planned', route: null, source: null, family: null, skeleton: null, bones: null, tris: null, heightM: c.heightM ?? null, kb: null, verbs: [], keep: [], usd: 0, license: null })),
246
+ ];
247
+ return {
248
+ ok: true, command: 'cast', id,
249
+ decisions: Object.fromEntries(['style.proportions', 'style.shape', 'style.palette', 'cast.family', 'cast.scale', 'cast.variation', 'rig.skeleton', 'rig.source', 'rig.bones'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state, why: d[k].why } : null])),
250
+ silhouette: d['style.shape']?.value?.silhouette ?? 'every character readable black on white at 64 px tall',
251
+ palette: pal ? ['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold'].map((k) => pal[k]).filter(Boolean) : [],
252
+ rows,
253
+ spend: artSpend(root, id),
254
+ };
255
+ }
256
+
257
+ export async function animCommand(root, sub, positional, flags, { log = () => {} } = {}) {
258
+ const known = ['plan', 'add', 'preview'];
259
+ const verb = known.includes(sub) ? sub : 'plan';
260
+ const args = known.includes(sub) ? positional.slice(2) : positional.slice(1);
261
+ const id = gameOf(root, args[0], `anim ${verb}`);
262
+ const C = await import('./characters.mjs');
263
+ let r;
264
+ if (verb === 'plan') r = C.animPlan(root, id);
265
+ else if (verb === 'add') {
266
+ if (!args[1]) return { ok: false, command: 'anim add', why: 'usage: homie-studio anim add <game> <character asset> --verbs jump,attack [--from <library item | file>]' };
267
+ r = await C.bakeClips(root, id, args[1], { verbs: list(flags.get('verbs')), from: flags.get('from') ?? null });
268
+ } else r = await C.animPreview(root, id, { asset: flags.get('asset') ?? args[1] ?? null, verbs: flags.has('verbs') ? list(flags.get('verbs')) : null, log });
269
+ writeArtSummary(root, id);
270
+ return r;
271
+ }
272
+
192
273
  /* ------------------------------------------------------------------ what a person reads */
193
274
 
194
275
  const MARK = { auto: '·', steered: '~', pinned: '●', locked: '■' };
@@ -197,7 +278,7 @@ export function artLines(r) {
197
278
  const L = [];
198
279
  switch (r.command) {
199
280
  case 'style init':
200
- L.push(`${r.summary}.`, ` ${r.picked.length} decision${r.picked.length === 1 ? '' : 's'} picked automatically (a ${r.genre} game)${r.kept.length ? `; ${r.kept.length} kept as the person set them` : ''}. ${r.file}`, ' Open the codex to change anything; steer, lock or the style board give the person control.');
281
+ L.push(`${r.summary}.`, ` ${r.picked.length} decision${r.picked.length === 1 ? '' : 's'} picked automatically (a ${r.genre} game)${r.kept.length ? `; ${r.kept.length} kept (set by the person, or what the game already draws)` : ''}. ${r.file}`, ' Open the codex to change anything; steer, lock or the style board give the person control.');
201
282
  break;
202
283
  case 'style': {
203
284
  L.push(`${r.id}: ${r.line}`, ` path: ${r.path}${r.budget ? `, art budget US$${r.budget.usd}` : ', free routes only (no art budget)'}`);
@@ -252,7 +333,8 @@ export function artLines(r) {
252
333
  if (r.items.length) L.push(` Add one: homie-studio assets add <game> ${r.items[0].id} [--height <m>]`);
253
334
  break;
254
335
  case 'assets add':
255
- L.push(`games/${r.game}: ${r.asset} added (${r.item ? `from the library: ${r.item}` : r.route}; licence ${r.license}).`, ...(r.after ? [` ${r.before.tris} -> ${r.after.tris} triangles, ${r.before.kb} -> ${r.after.kb} KB, ${r.after.heightM} m tall; raw kept in ${r.raw} (git-ignored)`] : []), ...(r.pinned?.length ? [` pinned by use: ${r.pinned.join(', ')}`] : []), ...(r.warnings ?? []).map((w) => ` note: ${w}`));
336
+ L.push(`games/${r.game}: ${r.asset} added (${r.item ? `from the library: ${r.item}` : r.route}; licence ${r.license}).`, ...(r.after ? [` ${r.before.tris} -> ${r.after.tris} triangles, ${r.before.kb} -> ${r.after.kb} KB, ${r.after.heightM} m tall${r.raw ? `; raw kept in ${r.raw} (git-ignored)` : ''}`] : []),
337
+ ...(r.skeleton ? [` ${r.family} skeleton ${r.skeleton} (${r.after?.bones ?? '?'} bones, ${r.after?.drawCalls ?? '?'} draw call${r.after?.drawCalls === 1 ? '' : 's'}${r.merged ? `, ${r.merged} parts merged` : ''}${r.dropped?.length ? `, left out: ${r.dropped.join(', ')}` : ''}); clips in ${r.anims}: ${r.verbs.join(', ')}${r.clipsFrom?.length ? ` (retargeted from ${r.clipsFrom.join(', ')})` : ''}${r.missing?.length ? `; no source for ${r.missing.join(', ')}` : ''}`] : []), ...(r.pinned?.length ? [` pinned by use: ${r.pinned.join(', ')}`] : []), ...(r.warnings ?? []).map((w) => ` note: ${w}`));
256
338
  break;
257
339
  case 'assets redo':
258
340
  L.push(`games/${r.game}: ${r.asset} made again from its raw file (free): ${r.before.tris} -> ${r.after.tris} triangles, ${r.after.kb} KB, ${r.after.heightM} m (${r.ops.join(', ')})`, ...(r.warnings ?? []).map((w) => ` note: ${w}`));
@@ -278,6 +360,24 @@ export function artLines(r) {
278
360
  case 'assets remove':
279
361
  L.push(r.ok ? `Removed ${r.asset} from the manifest${r.deleted.length ? `, and its shipped copy (${r.deleted.join(', ')})` : ''}.${r.kept.length ? ` Kept ${r.kept.join(', ')}: changed since it was recorded, so delete it yourself if it is unused.` : ''} A raw file in art/ stays.` : r.why);
280
362
  break;
363
+ case 'cast': {
364
+ const out = [`The cast of ${r.id}: ${r.rows.length} character${r.rows.length === 1 ? '' : 's'}`];
365
+ for (const [k, v] of Object.entries(r.decisions)) if (v) out.push(` ${k}: ${v.label} [${v.state}]`);
366
+ for (const row of r.rows) out.push(` ${row.state === 'made' ? 'made ' : 'planned'} ${row.id} (${row.kind}${row.route ? `, ${row.route}` : ''})${row.family ? `: ${row.family} skeleton ${row.skeleton}, ${row.bones} bones, ${row.tris} triangles, ${row.heightM} m, ${row.verbs.length} clips` : ''}${row.usd ? `, US$${row.usd}` : ''}`);
367
+ return out;
368
+ }
369
+ case 'anim plan': {
370
+ const out = [`Clips for ${r.game}: the game needs ${r.verbs.join(', ')}`];
371
+ for (const row of r.rows) {
372
+ out.push(` ${row.id} (${row.familyLabel}, ${row.skeleton}, ${row.bones} bones${row.animsKB ? `, its clip library ${row.animsKB} KB` : ''})`);
373
+ for (const c of row.clips) out.push(` ${c.have ? 'ok ' : 'MISS'} ${c.verb}${c.have ? ` <- ${c.source}${c.retargeted ? ` (retargeted from ${c.from})` : ''}` : ''}`);
374
+ if (row.extra.length) out.push(` also: ${row.extra.join(', ')}`);
375
+ }
376
+ for (const u of r.unrigged) out.push(` ${u}: no rig (a static model; the animate guide says how to rig one)`);
377
+ return out;
378
+ }
379
+ case 'anim add': return [`${r.asset}: ${r.added.length ? `added ${r.added.join(', ')}` : 'nothing new'} (its skeleton ${r.skeleton}: ${r.verbs.join(', ')}${r.kb ? `; ${r.kb} KB` : ''})${r.missing.length ? `; no source has ${r.missing.join(', ')}` : ''}${r.why ? ` (${r.why})` : ''}`];
380
+ case 'anim preview': return [`Previews in .studio/art/${r.game}/anim/:`, ...r.rows.map((row) => ` ${row.id}: ${row.verbs.map((v) => v.verb).join(', ')}${row.sheet ? ` (sheet ${row.sheet})` : ''}`)];
281
381
  case 'assets check':
282
382
  L.push(...checkLines(r));
283
383
  break;
package/lib/art-tools.mjs CHANGED
@@ -14,6 +14,14 @@
14
14
  * asset_check phone budgets, the validator, licences, staleness
15
15
  * asset_lineup true scale, silhouettes, palette drift, flags lineup card
16
16
  * asset_rights RIGHTS.md and every asset's licence rights card
17
+ * cast_plan the characters: proportions, silhouette, palette, skeleton family, source, clips, cost cast card
18
+ * character_make one generated, rigged character on the person's own fal account: a priced dry run unless approve;
19
+ * the A-pose concept first, then (after looking) Meshy's mesh and auto-rig, the library's clips
20
+ * retargeted onto it for free lineup card
21
+ * anim_plan each character's clips against the verbs the game needs, as looping previews; Feel opens the
22
+ * Game Lab on the move animation card
23
+ * anim_add more verbs for a character, retargeted onto its skeleton (free) animation card
24
+ * anim_preview the looping previews drawn again (free) animation card
17
25
  *
18
26
  * Work that needs the studio's own tools (rendering, optimising) runs the studio's pinned CLI as a job; decisions are
19
27
  * plain files and change here directly. Pictures go to cards as small data: URLs (each a few hundred KB at most).
@@ -21,7 +29,8 @@
21
29
  import { existsSync, readFileSync } from 'node:fs';
22
30
  import { join, resolve } from 'node:path';
23
31
  import { readManifest } from './asset-manifest.mjs';
24
- import { artSpend, writeArtSummary } from './art-cli.mjs';
32
+ import { artSpend, castView, writeArtSummary } from './art-cli.mjs';
33
+ import { animPlan } from './characters.mjs';
25
34
  import { CATALOGUE_IDS, decisionRows, lockDecision, oneLine, phaseProgress, pickDirection, readDecisions, setDecision, staleAssets, steerDecision, unlockDecision } from './decisions.mjs';
26
35
  import { libraryBase, loadIndex, searchLibrary, itemThumb } from './library.mjs';
27
36
  import { boardView } from './style-board.mjs';
@@ -33,6 +42,7 @@ export const ART_UI = Object.freeze({
33
42
  cast: 'ui://homie-studio/cast',
34
43
  lineup: 'ui://homie-studio/lineup',
35
44
  rights: 'ui://homie-studio/rights',
45
+ animation: 'ui://homie-studio/animation',
36
46
  });
37
47
 
38
48
  /** A picture file as a data: URL no bigger than `max` (a smaller JPEG when it is over), or null. */
@@ -77,6 +87,68 @@ function styleText(d) {
77
87
  ].join('\n');
78
88
  }
79
89
 
90
+ /** A character's small picture: its looping idle preview, else its library thumbnail, else its concept. */
91
+ async function characterThumb(h, root, game, row) {
92
+ const idle = join(root, '.studio', 'art', game, 'anim', `${row.id}-idle.webp`);
93
+ if (existsSync(idle)) return picture(h, idle, 40 * 1024);
94
+ const m = readManifest(root, game).assets.find((a) => a.id === row.id);
95
+ if (m?.from?.item) {
96
+ try { const lib = await loadIndex({ lib: libraryBase() }); const it = lib.index.items.find((x) => x.id === m.from.item); const t = it ? await itemThumb(lib.lib, it) : null; if (t) return `data:image/webp;base64,${Buffer.from(t).toString('base64')}`; } catch { /* no library here */ }
97
+ }
98
+ const concept = (m?.files ?? []).find((f) => f.role === 'concept');
99
+ return concept ? picture(h, join(root, 'games', game, concept.path), 60 * 1024) : null;
100
+ }
101
+
102
+ /** Which Game Lab take tunes a verb (lab.json): one named for it or whose note says it, else the default. */
103
+ function takeFor(lab, verb) {
104
+ const takes = Object.entries(lab?.takes ?? {});
105
+ const words = { attack: /swing|attack|strike|hit|punch|knock/i, jump: /jump|hop|leap/i, cast: /cast|spell/i, dodge: /dodge|roll/i, run: /run|dash|sprint/i };
106
+ const hit = takes.find(([k]) => k === verb) ?? takes.find(([k, t]) => (words[verb] ?? new RegExp(verb, 'i')).test(`${k} ${t?.note ?? ''}`));
107
+ return hit ? hit[0] : null;
108
+ }
109
+
110
+ /** The animation card's data: the plan, with looping previews for up to `max` characters (the answer stays small). */
111
+ function animationData(h, root, game, { asset = null, max = 4 } = {}) {
112
+ const plan = animPlan(root, game);
113
+ const dir = join(root, '.studio', 'art', game, 'anim');
114
+ let budget = 560 * 1024;
115
+ const order = asset ? [...plan.rows.filter((r) => r.id === asset), ...plan.rows.filter((r) => r.id !== asset)] : plan.rows;
116
+ // One character a skeleton first (its clips are its skeleton's), then the rest.
117
+ const seen = new Set(); const first = []; const rest = [];
118
+ for (const r of order) { if (!seen.has(r.skeleton) || r.id === asset) { seen.add(r.skeleton); first.push(r); } else rest.push(r); }
119
+ const shown = new Set([...first, ...rest].slice(0, Math.max(1, max)).map((r) => r.id));
120
+ // Each shown character gets an even share of the answer, its core verbs first, so none is left all placeholders.
121
+ const CORE = ['idle', 'run', 'jump', 'attack', 'cast', 'hit', 'walk', 'land'];
122
+ const share = Math.floor(budget / Math.max(1, shown.size));
123
+ const rows = order.map((r) => {
124
+ const have = [...r.clips.filter((c) => c.have).map((c) => c.verb), ...r.extra];
125
+ const verbs = [...CORE.filter((v) => have.includes(v)), ...have.filter((v) => !CORE.includes(v))];
126
+ const previews = {};
127
+ if (shown.has(r.id)) {
128
+ let left = Math.min(share, budget);
129
+ for (const v of verbs) {
130
+ const f = join(dir, `${r.id}-${v}.webp`);
131
+ if (!existsSync(f)) continue;
132
+ const pic = picture(h, f, 40 * 1024);
133
+ if (!pic || pic.length > left) continue;
134
+ left -= pic.length; budget -= pic.length; previews[v] = pic;
135
+ }
136
+ }
137
+ return { ...r, shown: shown.has(r.id), previews, previewed: Object.keys(previews).length };
138
+ });
139
+ const takes = plan.lab?.takes ? Object.fromEntries(Object.entries(plan.lab.takes).map(([k, t]) => [k, t?.note ?? ''])) : {};
140
+ const feel = Object.fromEntries(['jump', 'attack', 'cast', 'run', 'dodge', 'hit', 'land'].map((v) => [v, takeFor(plan.lab, v)]).filter(([, t]) => t));
141
+ return { kind: 'animation', game, verbs: plan.verbs, rows, decisions: plan.decisions, takes, feel, unrigged: plan.unrigged };
142
+ }
143
+
144
+ function animationText(d) {
145
+ return [
146
+ `Clips for ${d.game}: the game needs ${d.verbs.join(', ')}.`,
147
+ ...d.rows.map((r) => ` ${r.id} (${r.familyLabel}, ${r.bones} bones): ${r.clips.map((c) => `${c.verb}${c.have ? '' : ' MISSING'}`).join(', ')}${r.extra.length ? `; also ${r.extra.join(', ')}` : ''}${r.previewed ? ` (${r.previewed} previews on the card)` : ''}`),
148
+ ...(Object.keys(d.feel).length ? [`Feel: the Game Lab tunes ${Object.entries(d.feel).map(([v, t]) => `${v} (take "${t}")`).join(', ')} New beside Today (game_lab { "take": "<take>" }).`] : ['Feel: the game has no Game Lab take for a move yet (lab.json; the lab guide).']),
149
+ ].join('\n');
150
+ }
151
+
80
152
  export function artToolDefs(ctx, h) {
81
153
  const { ok, fail, cli, stillRunning, whyOf, needsInstall, ui, str, STUDIO_ARG, RO, RW } = h;
82
154
  const GAME = { game: str('The game\'s id (default: the only game)'), ...STUDIO_ARG };
@@ -327,5 +399,100 @@ export function artToolDefs(ctx, h) {
327
399
  return ok(`${x.file}:\n\n${x.text.slice(0, 6000)}`, { kind: 'rights', game, file: x.file, rows, problems: x.licence, text: x.text.slice(0, 20_000) });
328
400
  },
329
401
  },
402
+ {
403
+ name: 'cast_plan', title: 'Plan the characters',
404
+ description: 'The cast card for a game\'s characters: the decisions that shape them (proportions in heads and metres, the silhouette rule, the palette, the skeleton family, where rigs come from) and every character made or planned, with its picture, source (the free CC0 starter library, generated on the person\'s fal account, their own), skeleton and bones, triangles, height and clips. Free; nothing is made. A library character comes in with asset_add (its clips baked into its skeleton\'s clip library); a generated one with character_make (paid, priced first).',
405
+ inputSchema: { type: 'object', properties: { ...GAME } },
406
+ annotations: { title: 'Plan the characters', ...RO }, _meta: ui(ART_UI.cast),
407
+ run: async (a) => {
408
+ const root = ctx.root(a.studio);
409
+ const game = gameOf(root, a.game);
410
+ const c = castView(root, game);
411
+ const rows = [];
412
+ for (const r of c.rows) rows.push({ ...r, thumb: r.state === 'made' ? await characterThumb(h, root, game, r) : null });
413
+ const data = { kind: 'cast', mode: 'characters', game, decisions: c.decisions, silhouette: c.silhouette, palette: c.palette, rows, spend: c.spend };
414
+ return ok([`The characters of ${game} (${rows.length}):`, ...Object.entries(c.decisions).filter(([, v]) => v).map(([k, v]) => ` ${k}: ${v.label} [${v.state}]`), ...rows.map((r) => ` ${r.state} ${r.id}${r.family ? `: ${r.family} skeleton, ${r.bones} bones, ${r.tris} triangles, ${r.heightM} m, clips ${r.verbs.join(', ')}` : ''} (${r.route ?? 'planned'}${r.usd ? `, US$${r.usd}` : ''})`), 'anim_plan shows each one\'s clips, looping.'].join('\n'), data);
415
+ },
416
+ },
417
+ {
418
+ name: 'character_make', title: 'Make a character (paid)',
419
+ description: 'One generated, rigged character for a game, in its locked style, on the person\'s OWN fal account: a full-body concept image in an A-pose (the derived style prompt; the golden images, or like: a library character, as the style reference), then Meshy 7.1 image-to-3D with its humanoid auto-rig, then free local work: made phone-sized, its skeleton mapped to the standard, the starter library\'s CC0 clips retargeted onto it (idle, run, jump, attack, hit, ...), recorded with receipts and licence. About US$1.44 (US$0.035 concept + US$1.40 mesh and rig). Without approve it is a priced dry run: tell the person the price and ask. budget sets the cap they agreed to. Step 1 makes only the concept: LOOK at it; then step: "mesh" with approve. Humanoids with clear limbs only. Never in a loop.',
420
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('The new character\'s id, e.g. ranger'), card: str('Its codex card, e.g. "Players/Ranger"'), what: str('Who, in plain words: build, clothes, colours, what they carry'), height: { type: 'number', description: 'Its height in metres (default: the game\'s character height)' }, like: str('A starter-library character whose look it should match (a style reference), e.g. kaykit-adventurers/rogue'), step: str('concept (default) or mesh', { enum: ['concept', 'mesh'] }), approve: { type: 'boolean', description: 'The person agreed to the price' }, budget: { type: 'number', description: 'The cap in US dollars the person agreed to for this game\'s models' }, again: { type: 'boolean', description: 'A new concept with changed words (costs another concept)' } }, required: ['asset'] },
421
+ annotations: { title: 'Make a character (paid)', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(ART_UI.lineup),
422
+ run: async (a) => {
423
+ const root = ctx.root(a.studio);
424
+ const need = needsInstall(ctx, root); if (need) return need;
425
+ const game = gameOf(root, a.game);
426
+ const asset = String(a.asset ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
427
+ if (a.budget !== undefined) {
428
+ const b = await models(root, `budget for ${game}`, ['budget', game, '--cap', String(Number(a.budget))], { wait: 20_000 }).catch((e) => ({ error: e.message }));
429
+ if (b.error || b.job?.code) return fail(`The budget was not set: ${b.error ?? whyOf(b.job)}`);
430
+ }
431
+ const args = ['character', game, asset, ...(a.card ? ['--card', String(a.card)] : []), ...(a.what ? ['--what', String(a.what)] : []), ...(a.height ? ['--height', String(a.height)] : []), ...(a.like ? ['--like', String(a.like)] : []), ...(a.step === 'mesh' ? ['--mesh'] : []), ...(a.again ? ['--concept-again'] : []), ...(a.approve === true ? ['--yes'] : ['--dry-run'])];
432
+ let r;
433
+ try { r = await models(root, `${a.step === 'mesh' ? 'the rigged character' : 'the concept'} for ${asset}`, args, { wait: a.approve === true ? ctx.waitMs : 30_000 }); } catch (error) { return fail(error.message); }
434
+ if (!r.ended) return stillRunning(r.job, a.step === 'mesh' ? `The rigged character ${asset} (about 2 to 5 minutes)` : `The concept for ${asset}`);
435
+ const x = r.result ?? {};
436
+ writeArtSummary(root, game);
437
+ const concept = x.concept ? picture(h, join(root, x.concept), 260 * 1024) : null;
438
+ const request = { ...(a.card ? { card: String(a.card) } : {}), ...(a.what ? { what: String(a.what) } : {}), ...(a.height ? { height: Number(a.height) } : {}), ...(a.like ? { like: String(a.like) } : {}) };
439
+ const data = { kind: 'lineup', mode: 'make', character: true, game, asset, request, step: x.step ?? a.step ?? 'concept', dryRun: Boolean(x.dryRun), price: x.price ?? null, total: x.total ?? null, then: x.then ?? null, needs: x.needs ?? null, concept, model: x.model ?? null, before: x.before ?? null, after: x.after ?? null, usd: x.usd ?? null, verbs: x.verbs ?? [], skeleton: x.skeleton ?? null, spend: artSpend(root, game), next: x.next ?? null, why: x.why ?? null };
440
+ if (r.job.code !== 0 && !x.needs) return fail(`Not made: ${x.why ?? whyOf(r.job)}`, data);
441
+ const text = x.dryRun ? `${x.step === 'mesh' ? 'The rigged character' : `The concept for ${asset}`} would cost about US$${x.price?.usd} (${x.price?.basis})${x.total ? `; with the mesh and rig, about US$${x.total} for the character` : ''}, on the person's own fal account. Ask them; then character_make with approve: true (and budget: <their cap> the first time).`
442
+ : x.needs === 'approval' ? x.why
443
+ : x.step === 'concept' ? `The concept for ${asset} is made (US$${x.usd}). LOOK at it (file_read ${x.concept}): one character, whole, in an A-pose, in the game's style, on a plain background? Then character_make { "asset": "${asset}", "step": "mesh", "approve": true } (about US$${x.then?.usd ?? '1.40'}), or new words with again: true.`
444
+ : x.step === 'mesh' ? `${asset} is a rigged character in games/${game}: ${x.after?.tris} triangles, ${x.after?.kb} KB, ${x.after?.heightM} m, skeleton ${x.skeleton}; clips (retargeted, free): ${(x.verbs ?? []).join(', ')} (US$${x.usd}; receipts ${x.receipts?.join(', ')}). anim_preview shows its clips looping; asset_lineup shows it beside the rest.`
445
+ : (x.next ?? JSON.stringify(x));
446
+ return ok(text, data);
447
+ },
448
+ },
449
+ {
450
+ name: 'anim_plan', title: 'Clips',
451
+ description: 'The animation card: every character\'s clips against the verbs the game needs (its anim.clips decision: idle, walk, run, jump, attack, hit, die, ...), each as a LOOPING PREVIEW (when drawn: anim_preview), where it came from (its own, or the CC0 library\'s retargeted onto its skeleton), what is missing (anim_add adds it, free), and Feel: the Game Lab on the move (game_lab with the take that plays it), New beside Today. Free.',
452
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('Optional: the character to show first') } },
453
+ annotations: { title: 'Clips', ...RO }, _meta: ui(ART_UI.animation),
454
+ run: async (a) => {
455
+ const root = ctx.root(a.studio);
456
+ const game = gameOf(root, a.game);
457
+ const d = animationData(h, root, game, { asset: a.asset ?? null });
458
+ if (!d.rows.length) return fail(`${game} has no rigged characters yet: asset_add a library character (assets_find "knight" kind character), or character_make`, d);
459
+ return ok(animationText(d), d);
460
+ },
461
+ },
462
+ {
463
+ name: 'anim_add', title: 'Add clips',
464
+ description: 'More verbs for a character (jump, attack, hit, die, emote, cast, block, dodge, ...): baked into its skeleton\'s clip library at build time, its own clip where it has one, else retargeted from the starter library\'s CC0 humanoid clips (from: another library item or a file). Free, a few seconds; every character with that skeleton gets them.',
465
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('The character'), verbs: { description: 'Verbs to add, e.g. ["jump","attack"] or "jump,attack"' }, from: str('Optional: a library item or a file to take them from') }, required: ['asset', 'verbs'] },
466
+ annotations: { title: 'Add clips', ...RW }, _meta: ui(ART_UI.animation),
467
+ run: async (a) => {
468
+ const root = ctx.root(a.studio);
469
+ const need = needsInstall(ctx, root); if (need) return need;
470
+ const game = gameOf(root, a.game);
471
+ const verbs = (Array.isArray(a.verbs) ? a.verbs : String(a.verbs ?? '').split(',')).map((v) => String(v).trim()).filter(Boolean);
472
+ const r = await cli(ctx, root, `clips for ${a.asset}`, ['anim', 'add', game, String(a.asset), '--verbs', verbs.join(','), ...(a.from ? ['--from', String(a.from)] : [])]);
473
+ if (!r.ended) return stillRunning(r.job, 'Baking the clips');
474
+ if (r.job.code !== 0) return fail(`Not added: ${whyOf(r.job)}`);
475
+ const p = await cli(ctx, root, `previews of ${a.asset}`, ['anim', 'preview', game, '--asset', String(a.asset)]).catch(() => null);
476
+ const d = animationData(h, root, game, { asset: String(a.asset) });
477
+ const x = r.result ?? {};
478
+ return ok(`${a.asset}: ${x.added?.length ? `added ${x.added.join(', ')}` : 'nothing new'}${x.missing?.length ? `; no source has ${x.missing.join(', ')}` : ''}.${p?.ended ? '' : ' (Its previews are still being drawn.)'}\n${animationText(d)}`, { ...d, added: x.added ?? [], missing: x.missing ?? [] });
479
+ },
480
+ },
481
+ {
482
+ name: 'anim_preview', title: 'Draw the clips',
483
+ description: 'Draw every character\'s clips as small looping previews (animated WebP, under the game\'s light; free, one headless Chrome on this computer, a few seconds a character) and answer with the animation card.',
484
+ inputSchema: { type: 'object', properties: { ...GAME, asset: str('Optional: only this character') } },
485
+ annotations: { title: 'Draw the clips', ...RW }, _meta: ui(ART_UI.animation),
486
+ run: async (a) => {
487
+ const root = ctx.root(a.studio);
488
+ const need = needsInstall(ctx, root); if (need) return need;
489
+ const game = gameOf(root, a.game);
490
+ const r = await cli(ctx, root, `clip previews for ${game}`, ['anim', 'preview', game, ...(a.asset ? ['--asset', String(a.asset)] : [])]);
491
+ if (!r.ended) return stillRunning(r.job, 'The previews');
492
+ if (r.job.code !== 0) return fail(`Not drawn: ${whyOf(r.job)}`);
493
+ const d = animationData(h, root, game, { asset: a.asset ?? null });
494
+ return ok(`${animationText(d)}\nSheets (a person reads them with file_read): ${(r.result?.rows ?? []).map((x) => x.sheet).filter(Boolean).join(', ')}`, d);
495
+ },
496
+ },
330
497
  ];
331
498
  }