@gtanczyk/diorama 0.4.1 → 0.5.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.
@@ -171,6 +171,20 @@ defineAction({
171
171
  },
172
172
  });
173
173
 
174
+ defineAction({
175
+ name: 'hero_size_set',
176
+ description: 'Say how tall the hero stands in a place, against a thing painted there: times that thing\'s painted height ("about as tall as the candle jar": 1). '
177
+ + 'Use it when the hero is far too big or small for the place, or differs from place to place in one world (a desk seen whole and a close-up of a mug), '
178
+ + 'or when only a strip of floor is walkable because the hero would be taller than the picture at the front. Sets the place\'s scale. Kept across rebuilds. No repaint: reruns derive to verify for that place.',
179
+ input: z.object({ slug: Slug, place: z.string().min(1), like: z.string().min(1), times: z.number().positive().max(20) }),
180
+ job: true,
181
+ async run({ slug, place, like, times }, ctx) {
182
+ return fixPlace(ctx, slug, place, 'derive', `hero size ${times} x ${like}`, (c) => {
183
+ c.heroSize = { like, times };
184
+ });
185
+ },
186
+ });
187
+
174
188
  defineAction({
175
189
  name: 'hero_start_set',
176
190
  description: 'Set where the hero begins in a place (painting pixels, on walkable floor; the nearest roomy cell of the main floor is used): '
@@ -16,6 +16,7 @@ import { closeFaces, placeDepth } from '../scene/occlusion-check.ts';
16
16
  import { gridOf } from '../scene/verify.ts';
17
17
  import { unionAll } from '../image/raster.ts';
18
18
  import { WorldFiles } from '../world/files.ts';
19
+ import { readBodies } from '../scene/cast.ts';
19
20
 
20
21
  type MasksJson = { things: Record<string, Omit<ThingIn, 'key' | 'mask'>> };
21
22
 
@@ -54,7 +55,8 @@ async function walkPicture(painting: Buffer, g: Geometry, footprint: ThingIn['ma
54
55
  // The hero's size at three depths, as little figures spread over the floor (far left, middle, near right):
55
56
  // stacked boxes in one column read as a path, not as a person.
56
57
  const rows = g.walk.map((row, r) => (row.includes('#') ? r : -1)).filter((r) => r >= 0);
57
- for (const [q, across] of [[0.1, 0.2], [0.5, 0.5], [0.95, 0.8]] as const) {
58
+ // No walkable row at all (NO_FLOOR): no figures to draw; verify reports the missing floor.
59
+ for (const [q, across] of rows.length ? ([[0.1, 0.2], [0.5, 0.5], [0.95, 0.8]] as const) : []) {
58
60
  const r = rows[Math.floor(q * (rows.length - 1))];
59
61
  const cols = [...g.walk[r]].flatMap((ch, c) => (ch === '#' ? [c] : []));
60
62
  // The walkable cell nearest to a quarter, the middle and three quarters of the frame, so they stand apart.
@@ -94,6 +96,7 @@ defineCommand({
94
96
  const files = new WorldFiles(slug);
95
97
  const world = files.world();
96
98
  const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
99
+ const heroMetres = readBodies(files, fs.existsSync)[world.hero.key]?.height;
97
100
  await Promise.all(places.map(async (place) => {
98
101
  const at = (...parts: string[]) => files.place(place.key, ...parts);
99
102
  // NPCs are drawn by the runtime, not painted: moving one only moves its box (and where the hero talks to it).
@@ -121,7 +124,7 @@ defineCommand({
121
124
  console.log(` ${place.key}: no depth model for walls (${e.message})`);
122
125
  return undefined;
123
126
  });
124
- const out = await derive(files.memo, { place, layout: asPainted, things, floors, painting, plates, carry, lastScale, previousScale, ...(start && { start }), ...(upright && { upright }), ...(said && { said }) }, (line) => console.log(` ${place.key}: ${line}`));
127
+ const out = await derive(files.memo, { place, layout: asPainted, things, floors, painting, plates, carry, lastScale, previousScale, ...(start && { start }), ...(upright && { upright }), ...(said && { said }), ...(heroMetres && { heroMetres }), ...(readCorrections(at()).heroSize && { heroSize: readCorrections(at()).heroSize }) }, (line) => console.log(` ${place.key}: ${line}`));
125
128
  // Shadows only with a light: without one the runtime has nothing to darken by.
126
129
  const lit = out.shadows.light && maskArea(out.shadows.mask) > 0;
127
130
  if (lit) files.write(at('masks', 'shadows.png'), await maskToPng(out.shadows.mask));
@@ -149,6 +149,14 @@ async function makeModel(files: WorldFiles, key: string, take: number, given: st
149
149
  try {
150
150
  views = await paintViews(files.memo, key, who.looks ?? '', painting, take, creature, (name, png) => files.write(out(`${name}.png`), png));
151
151
  } catch (error) {
152
+ // A creature whose sides never showed one body: no model, the painted figure hops as it is (an older model too
153
+ // was made from such sides, so it goes).
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ if (creature && message.startsWith('its 4 sides do not show one creature')) {
156
+ if (fs.existsSync(out('model.json'))) fs.renameSync(out('model.json'), out('model-rejected.json'));
157
+ console.log(`${key}: no 3D model: ${message}; the painted figure hops as it is`);
158
+ return message;
159
+ }
152
160
  // Refused even in calm words: the model made before stays (a changed prompt reran it), else the 2D rig walks.
153
161
  if (!refusedByFilter(error)) throw error;
154
162
  if (fs.existsSync(out('model.json')) && fs.existsSync(out('model.glb'))) {
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import { writeBrief } from '../scene/brief.ts';
6
6
  import { seedRan } from '../scene/carry.ts';
7
- import { addsPerson, critique, paintedPeople, repaint } from '../scene/critic.ts';
7
+ import { addsPerson, castLooks, critique, paintedPeople, repaint } from '../scene/critic.ts';
8
8
  import { readCorrections, writeCorrections } from '../scene/corrections.ts';
9
9
  import { Layout } from '../scene/layout.ts';
10
10
  import { paintPlace } from '../scene/paint.ts';
@@ -98,7 +98,7 @@ defineCommand({
98
98
  const again = await critique(files.memo, worded(place, words), brief, first);
99
99
  // People are looked for on the painting as the game shows it, with every fix in, not only the first one.
100
100
  const shown = fs.existsSync(files.place(place.key, 'painting.png')) ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
101
- for (const p of shown === first || place.kind === 'drive' ? [] : await paintedPeople(files.memo, place.key, shown)) {
101
+ for (const p of shown === first || place.kind === 'drive' ? [] : await paintedPeople(files.memo, place.key, shown, castLooks(place))) {
102
102
  again.issues.push({ key: 'person', problem: 'unwanted', region: p.box, fix: `Paint out the ${p.what}: continue what is behind them (planks, water, wall), add nothing new` });
103
103
  }
104
104
  const had = new Set(found.issues.map((i) => JSON.stringify([i.region, i.fix])));
@@ -58,6 +58,7 @@ function describe(e: StudioEvent, verbose: boolean): string | null {
58
58
  case 'agent.message': return `agent: ${String(e.text).slice(0, 200)}`;
59
59
  case 'agent.turn.end': return e.status === 'completed' ? null : `agent stopped: ${e.error ?? e.status}`;
60
60
  case 'agent.error': return `agent error: ${e.text}`;
61
+ case 'agent.limit': return `agent ${e.text}`;
61
62
  case 'agent.agent': return `agent is now ${e.label}`;
62
63
  case 'log': return verbose || e.stream === 'err' ? ` ${e.line}` : null;
63
64
  default: return null;
@@ -14,7 +14,7 @@ import { resumePendingVoices } from '../sound/voices.ts';
14
14
 
15
15
  defineCommand({
16
16
  name: 'voices',
17
- usage: 'voices <slug> cast a voice per character (stock, or designed by ElevenLabs Voice Design for children, teens and the old) and speak every story line, checked by Muse Voice Transcribe; conversations, intro and endings [--all: every line; always for a world for kids, whose players cannot read] [--recast] [--draft: Flash and 2 takes; default final uses Multilingual v2 and up to 3 takes]',
17
+ usage: 'voices <slug> cast a voice per character (stock, or designed by ElevenLabs Voice Design for children, teens and the old) and speak every story line, checked by Muse Voice Transcribe; every line the hero and the others say, intro and endings [--essential: only conversations, intro and endings, not the hero\'s looks, uses and locked doors] [--recast] [--draft: Flash and 2 takes; default final uses Multilingual v2 and up to 3 takes]',
18
18
  async run(args) {
19
19
  // Keep this generation at one quality even if Setup changes while it runs.
20
20
  process.env.DIORAMA_SOUND = soundQuality(args);
@@ -27,7 +27,9 @@ defineCommand({
27
27
  if (!elevenKey()) return void console.log('no ELEVENLABS_API_KEY: lines stay unspoken');
28
28
  const story = Story.parse(files.readJson(files.path('story.json')));
29
29
  const guard = await creditGuard(elevenKey(), 'voices');
30
- const lines = storyLines(world, story, args.includes('--all') || world.audience === 'kids');
30
+ // Every line by default: a hero who answers out loud in conversations and is silent looking at things reads as broken.
31
+ // --essential keeps to conversations, intro and endings (a world for kids always gets every line).
32
+ const lines = storyLines(world, story, !args.includes('--essential') || world.audience === 'kids');
31
33
  const outFile = files.path('voices.json');
32
34
  const old = fs.existsSync(outFile) ? Voices.parse(files.readJson(outFile)) : undefined;
33
35
  let cast: Cast;
@@ -158,6 +158,45 @@ async function paintCreatureViews(memo: Memo, key: string, looks: string, painti
158
158
  label: `${key}-view-${facing}`, size: SIZE, take, images: [front, back],
159
159
  prompt: `The same creature as in both pictures (front and back views), same colours and painted style, now seen exactly from the side in profile, facing to the ${facing} of the picture (${looks}), ${REST}. Same size and position in the frame as the pictures. ${PLAIN}`,
160
160
  }).then((png) => (onView?.(facing, png), png));
161
- const [left, right] = await Promise.all([side('left'), side('right')]);
162
- return { front, back, left, right };
161
+ const views: Record<ViewName, Buffer> = { front, back, left: front, right: front };
162
+ [views.left, views.right] = await Promise.all([side('left'), side('right')]);
163
+ // Muse can leave out or add a part between views (a mug drawn standing on a bottle loses the bottle in front and
164
+ // keeps it from the side): TRELLIS cannot make one body of that and the model comes out torn. Views that do not
165
+ // show the same creature as the front are painted again; still not one creature after that, there is no model.
166
+ for (let round = 0; round < CREATURE_ROUNDS; round++) {
167
+ const wrong = await checkCreature(memo, key, views, round);
168
+ const redo = Object.keys(wrong) as ViewName[];
169
+ if (!redo.length) return views;
170
+ console.log(` ${key}: ${redo.map((v) => `${v} view: ${wrong[v]}`).join('; ')}; painting ${redo.length === 1 ? 'it' : 'them'} again`);
171
+ const t = take + 1 + round;
172
+ if (wrong.back) (views.back = await edit({
173
+ label: `${key}-view-back`, size: SIZE, take: t, images: [front],
174
+ prompt: `The same creature as in the picture (${looks}), with exactly the same parts and nothing more or less, same colours and painted style, in exactly the same pose, seen straight from behind: we see its back, its face is not visible. Same size and position in the frame as the picture. ${PLAIN}`,
175
+ }), onView?.('back', views.back));
176
+ await Promise.all((['left', 'right'] as const).filter((f) => wrong[f]).map(async (f) => {
177
+ views[f] = await edit({
178
+ label: `${key}-view-${f}`, size: SIZE, take: t, images: [front, views.back],
179
+ prompt: `The same creature as in both pictures (front and back views), with exactly the same parts as the front and nothing more or less, same colours and painted style, now seen exactly from the side in profile, facing to the ${f} of the picture (${looks}), ${REST}. Same size and position in the frame as the pictures. ${PLAIN}`,
180
+ });
181
+ onView?.(f, views[f]);
182
+ }));
183
+ }
184
+ const still = await checkCreature(memo, key, views, CREATURE_ROUNDS);
185
+ if (Object.keys(still).length) throw new Error(`its 4 sides do not show one creature (${Object.entries(still).map(([v, why]) => `${v}: ${why}`).join('; ')})`);
186
+ return views;
187
+ }
188
+
189
+ /** Rounds of repainting creature views that do not match the front. */
190
+ const CREATURE_ROUNDS = 2;
191
+
192
+ /** Which of the back and side views show a different creature from the front: a part left out or added (what it stands on, legs, a handle), another shape. */
193
+ export async function checkCreature(memo: Memo, key: string, views: Record<ViewName, Buffer>, round = 0): Promise<Partial<Record<ViewName, string>>> {
194
+ const names: ViewName[] = ['front', 'back', 'left', 'right'];
195
+ const a = await askSparkJson<Record<string, { ok?: unknown; why?: unknown }>>(memo, {
196
+ label: `views-creature-check:${key}#${round}`, images: await Promise.all(names.map((v) => small(views[v]))),
197
+ prompt: 'Four pictures of one creature or living object, meant to be the same body seen from the front, from behind, from its left side and from its right side, so a 3D model can be built from them. '
198
+ + 'Compare pictures 2, 3 and 4 with picture 1 (the front). One is wrong when it shows a part the front does not, or lacks one the front has (something it stands on or in, a base, a bottle, a stand, legs, arms, a tail, a handle, a hat), or a clearly different body shape or size. Colours, light and the turned view itself are fine. '
199
+ + `Reply with JSON only: {${names.slice(1).map((v) => `"${v}": {"ok": true or false, "why": "a few words"}`).join(', ')}}`,
200
+ });
201
+ return Object.fromEntries(names.slice(1).flatMap((v) => (a[v]?.ok === false ? [[v, String(a[v]?.why ?? 'not the same creature as the front')]] : [])));
163
202
  }
@@ -19,8 +19,10 @@ import { pasteInside } from './plates.ts';
19
19
  export const BACKDROP_GROW = 14;
20
20
 
21
21
  /**
22
- * Fills the masked pixels from their surroundings: a pyramid of averages of the known pixels,
23
- * read back from the finest level that has any. Smooth, never sharp; fine for a strip of a few px.
22
+ * Fills the masked pixels from their surroundings: a pyramid of averages of the known pixels (pull), then each
23
+ * level's empty cells read from the next coarser level, bilinearly (push), down to the hole's pixels. Smooth,
24
+ * never sharp. Read back nearest-cell, a big hole came out in square blocks of the coarse levels (64 px and more),
25
+ * which a tone correction carried into the backdrop as square patches round a lamp's light.
24
26
  * With `known`, only those pixels feed the fill.
25
27
  */
26
28
  export function smearFill(rgb: Uint8Array, W: number, H: number, hole: Mask, known?: Mask): Uint8Array {
@@ -46,18 +48,38 @@ export function smearFill(rgb: Uint8Array, W: number, H: number, hole: Mask, kno
46
48
  }
47
49
  levels.push(next);
48
50
  }
51
+ // Each level as colours: its own average where it has known pixels, else read from the level above it.
52
+ const colours = levels.map((l) => new Float32Array(l.w * l.h * 3));
53
+ const filled = levels.map((l) => Uint8Array.from(l.weight, (v) => (v > 0 ? 1 : 0)));
54
+ const sample = (k: number, fx: number, fy: number, out: Float32Array, at: number) => {
55
+ const l = levels[k];
56
+ const col = colours[k];
57
+ const x = Math.max(0, Math.min(l.w - 1, fx));
58
+ const y = Math.max(0, Math.min(l.h - 1, fy));
59
+ const [x0, y0] = [Math.floor(x), Math.floor(y)];
60
+ const [x1, y1] = [Math.min(l.w - 1, x0 + 1), Math.min(l.h - 1, y0 + 1)];
61
+ const [ax, ay] = [x - x0, y - y0];
62
+ for (let c = 0; c < 3; c++) {
63
+ const top = col[(y0 * l.w + x0) * 3 + c] * (1 - ax) + col[(y0 * l.w + x1) * 3 + c] * ax;
64
+ const bottom = col[(y1 * l.w + x0) * 3 + c] * (1 - ax) + col[(y1 * l.w + x1) * 3 + c] * ax;
65
+ out[at + c] = top * (1 - ay) + bottom * ay;
66
+ }
67
+ };
68
+ for (let k = levels.length - 1; k >= 1; k--) {
69
+ const l = levels[k];
70
+ for (let j = 0; j < l.w * l.h; j++) {
71
+ if (filled[k][j]) for (let c = 0; c < 3; c++) colours[k][j * 3 + c] = l.sum[j * 3 + c] / l.weight[j];
72
+ else if (k < levels.length - 1) sample(k + 1, ((j % l.w) + 0.5) / 2 - 0.5, (Math.floor(j / l.w) + 0.5) / 2 - 0.5, colours[k], j * 3);
73
+ }
74
+ }
49
75
  const out = rgb.slice();
76
+ if (levels.length === 1) return out;
77
+ const px = new Float32Array(3);
50
78
  for (let i = 0; i < W * H; i++) {
51
- if (!hole.bits[i]) continue;
52
- let [x, y] = [i % W, Math.floor(i / W)];
53
- for (const l of levels) {
54
- const j = y * l.w + x;
55
- if (l.weight[j] > 0) {
56
- for (let c = 0; c < 3; c++) out[i * 3 + c] = l.sum[j * 3 + c] / l.weight[j];
57
- break;
58
- }
59
- [x, y] = [x >> 1, y >> 1];
60
- }
79
+ // A known pixel in the hole (with `known`) keeps its own colour.
80
+ if (!hole.bits[i] || base.weight[i] > 0) continue;
81
+ sample(1, ((i % W) + 0.5) / 2 - 0.5, (Math.floor(i / W) + 0.5) / 2 - 0.5, px, 0);
82
+ for (let c = 0; c < 3; c++) out[i * 3 + c] = Math.round(px[c]);
61
83
  }
62
84
  return out;
63
85
  }
@@ -67,6 +67,12 @@ export const Corrections = z.object({
67
67
  take: z.number().int().min(0).optional(),
68
68
  /** Where the hero begins in this place (hero_start_set), instead of the front middle of the biggest floor. */
69
69
  start: XY.optional(),
70
+ /**
71
+ * How tall the hero stands here, against a thing painted in this place (hero_size_set): `times` that thing's painted
72
+ * height. The place's scale is set from it, over Spark's guess of a person. One world's places can come out ten
73
+ * times apart (a desk seen whole, people-sized, and a close-up of a mug, props-sized), and the hero with them.
74
+ */
75
+ heroSize: z.object({ like: z.string().min(1), times: z.number().positive().max(20) }).optional(),
70
76
  /** This place's own painting style over world.json's (style_set with scope place). */
71
77
  style: z.string().min(10).optional(),
72
78
  });
@@ -43,7 +43,7 @@ Reply with JSON only: {"issues": [{"key": "...", "problem": "missing | wrong | f
43
43
  // The critic weighs the whole picture and lets a figure pass when the place's words name one ("a child floats
44
44
  // candle boats"); people are asked about on their own, as every one of them is a double or a stranger.
45
45
  // A drive's hero is painted at the wheel on purpose: no one is looked for there.
46
- const people = place.kind === 'drive' ? [] : await paintedPeople(memo, place.key, painting);
46
+ const people = place.kind === 'drive' ? [] : await paintedPeople(memo, place.key, painting, castLooks(place));
47
47
  const near = (a: number[], b: number[]) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) < 120;
48
48
  const extra = people.filter((p) => !found.issues.some((i) => i.problem === 'unwanted' && near(i.region, p.box))).map((p) => ({
49
49
  key: 'person', problem: 'unwanted' as const, region: pad(p.box, 24),
@@ -52,6 +52,13 @@ Reply with JSON only: {"issues": [{"key": "...", "problem": "missing | wrong | f
52
52
  return { issues: [...found.issues, ...extra].slice(0, 6) };
53
53
  }
54
54
 
55
+ /**
56
+ * How the place's NPCs look. A drawn world's NPC is often a thing (a steaming mug, a candle in a jar) that the place's
57
+ * words also name as part of the scene, so the painter paints it in and the critic, looking for people, lets it pass:
58
+ * the game then draws its figure beside the painted one, a double. They are looked for by their looks too.
59
+ */
60
+ export const castLooks = (place: Place) => place.things.filter((t) => t.role === 'npc').map((t) => t.looks ?? t.key.replace(/-/g, ' '));
61
+
55
62
  const PERSON = /\b(child|children|kid|boy|girl|man|woman|men|women|person|people|figure|keeper|seller|vendor|fisherman|sailor|someone|crowd|silhouette)s?\b/i;
56
63
 
57
64
  /**
@@ -63,9 +70,12 @@ export const addsPerson = (i: { problem: string; fix: string }) => i.problem !==
63
70
  const People = z.object({ people: z.array(z.object({ what: z.string(), box: z.tuple([z.number(), z.number(), z.number(), z.number()]) })).max(6) });
64
71
 
65
72
  /** Every person or figure painted in, with a box: none belongs in a background, the game adds its people itself. */
66
- export async function paintedPeople(memo: Memo, key: string, painting: Buffer): Promise<Array<{ what: string; box: [number, number, number, number] }>> {
73
+ export async function paintedPeople(memo: Memo, key: string, painting: Buffer, cast: string[] = []): Promise<Array<{ what: string; box: [number, number, number, number] }>> {
74
+ const others = cast.length
75
+ ? `\nAlso list each of these characters if it is painted in, however it is drawn (as an object, an animal or a creature, standing on or inside something else): ${cast.join('; ')}. The game draws them over the picture itself, so a painted one would be a double. Box only the character, not what it stands on.`
76
+ : '';
67
77
  const prompt = `This is a painted background (${FRAME.width}x${FRAME.height} px) for a 2D adventure. It must show no people at all.
68
- List every person or human figure painted in it: adults, children, sitting, crouching or far away, a silhouette in a window or a doorway. Not statues, scarecrows, posters or pictures of people.
78
+ List every person or human figure painted in it: adults, children, sitting, crouching or far away, a silhouette in a window or a doorway. Not statues, scarecrows, posters or pictures of people.${others}
69
79
  For each give what it is in a few words ("a crouching child in a grey hoodie") and its box [x0, y0, x1, y1] in pixels, tight around the figure.
70
80
  Reply with JSON only: {"people": [{"what": "...", "box": [0, 0, 0, 0]}]}`;
71
81
  try {
@@ -33,10 +33,15 @@ export type DeriveIn = { place: Place; layout: Layout; things: ThingIn[]; floors
33
33
  * Floor someone said is floor: brush strokes that add to a floor (the person's, the agent's, the free bridge's)
34
34
  * and floor cells the person added. It is walked on whatever thing's mask lies over it.
35
35
  */
36
- said?: Mask };
36
+ said?: Mask;
37
+ /** How tall the hero stands (cast/figures.json), in metres; the scale is for a grown-up. Absent: a grown-up. */
38
+ heroMetres?: number;
39
+ /** The hero as tall as `times` this thing's painted height (corrections heroSize): sets the scale. */
40
+ heroSize?: { like: string; times: number } };
37
41
  export type Derived = { geometry: Geometry; grid: Grid; blobs: Blob[]; footprint: Mask; probes: Sample[]; shadows: Shadows };
38
42
 
39
43
  const { width: W, height: H } = FRAME;
44
+ const round3 = (v: number) => Math.round(v * 1000) / 1000;
40
45
  /** The tallest the hero may be: three quarters of the picture. */
41
46
  export const NEAR_MAX_PX = Math.round(0.75 * H);
42
47
  /** How far (px) a layout light's point may lie outside the thing it belongs to: a glow drawn just off the lamp. */
@@ -362,14 +367,28 @@ export async function derive(memo: Memo, input: DeriveIn, log: (line: string) =>
362
367
  scale = { horizonY: Math.min(layout.horizon, topY - 40), pxPerY: 0.5, source: 'layout' };
363
368
  flag('scale: guessed from the layout horizon');
364
369
  }
370
+ // The person said how tall the hero is here, against a painted thing: the scale follows it (the horizon stays).
371
+ const heroMetres = input.heroMetres ?? PERSON_M;
372
+ const like = input.heroSize && things.find((t) => t.key === input.heroSize!.like);
373
+ const likeBox = like ? maskBox(like.mask) : undefined;
374
+ if (input.heroSize && !likeBox) flag(`hero size: no thing ${input.heroSize.like} with a mask here; the scale stays`);
375
+ if (input.heroSize && likeBox) {
376
+ const footY = likeBox[3];
377
+ const horizonY = scale.horizonY < footY - 20 ? scale.horizonY : Math.min(layout.horizon, footY - 40, topY - 40);
378
+ const personAt = (input.heroSize.times * (likeBox[3] - likeBox[1]) * PERSON_M) / heroMetres;
379
+ scale = { horizonY, pxPerY: round3(personAt / Math.max(1, footY - horizonY)), source: scale.source };
380
+ log(`scale from the hero's size: ${input.heroSize.times} x ${input.heroSize.like} (${Math.round((likeBox[3] - likeBox[1]) * input.heroSize.times)} px at y ${footY})`);
381
+ }
365
382
 
366
383
  // The frame gate: no standing where a good part of the hero would be outside the picture.
367
384
  const sized = scale;
385
+ // The gates are for the hero as tall as it is: a small one (a figurine among props) fits where a grown-up would not.
386
+ const heroShare = Math.min(1, heroMetres / PERSON_M);
368
387
  let framed = 0;
369
388
  grid.open.forEach((v, i) => {
370
389
  if (!v) return;
371
390
  const [x, y] = grid.centre(i);
372
- const h = Math.max(8, sized.pxPerY * (y - sized.horizonY));
391
+ const h = Math.max(8, heroShare * sized.pxPerY * (y - sized.horizonY));
373
392
  if (x < 0.1 * h || x > W - 0.1 * h || y < 0.7 * h) {
374
393
  grid.open[i] = 0;
375
394
  shut[i] = 1;
@@ -378,7 +397,7 @@ export async function derive(memo: Memo, input: DeriveIn, log: (line: string) =>
378
397
  });
379
398
  // The near gate: in a close-up interior the front rows sit right under the camera, where the hero would be
380
399
  // taller than the picture (a bakery at 942 px). Those rows are foreground to look past, not floor to stand on.
381
- const tooNear = [...grid.open.keys()].filter((i) => grid.open[i] && sized.pxPerY * (grid.centre(i)[1] - sized.horizonY) > NEAR_MAX_PX);
400
+ const tooNear = [...grid.open.keys()].filter((i) => grid.open[i] && heroShare * sized.pxPerY * (grid.centre(i)[1] - sized.horizonY) > NEAR_MAX_PX);
382
401
  if (tooNear.length && grid.count() - tooNear.length >= 200) {
383
402
  tooNear.forEach((i) => (grid.open[i] = 0, shut[i] = 1));
384
403
  framed += tooNear.length;
@@ -5,7 +5,7 @@
5
5
  import { randomBytes } from 'node:crypto';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
- import { antigravity, claude, codexLive, createAgentSession, detectAgents, museLive, type CodingAgent, withoutProviderCredentials, type AgentSession, type ApprovalDecision, type ApprovalRequest, type SessionState, type SessionTurn } from 'genaicode/agents';
8
+ import { antigravity, claude, codexLive, createAgentSession, detectAgents, museLive, type CodingAgent, withoutProviderCredentials, type AgentSession, type ApprovalDecision, type ApprovalRequest, type SessionState, type SessionTurn, type AgentBudget, type RateLimit } from 'genaicode/agents';
9
9
  import { configDir, redact, withoutSecrets } from '../secrets.ts';
10
10
  import type { Events } from './events.ts';
11
11
  import { History } from './history.ts';
@@ -61,6 +61,22 @@ export function studioTool(request: ApprovalRequest): boolean {
61
61
  */
62
62
  export const shellTool = (name: string) => /(^|[/_.])(shell|bash|sh|exec|exec_command|repl|js)$/i.test(name);
63
63
 
64
+ /** DIORAMA_AGENT_MAX_COST (dollars) and DIORAMA_AGENT_MAX_TOKENS cap each world's conversation; unset, nothing is capped. */
65
+ export function agentBudget(env: NodeJS.ProcessEnv = process.env): AgentBudget | undefined {
66
+ const cost = Number(env.DIORAMA_AGENT_MAX_COST);
67
+ const tokens = Number(env.DIORAMA_AGENT_MAX_TOKENS);
68
+ const budget: AgentBudget = { ...(cost > 0 && { maxCostUsd: cost }), ...(tokens > 0 && { maxTokens: Math.floor(tokens) }) };
69
+ return Object.keys(budget).length ? budget : undefined;
70
+ }
71
+
72
+ /** An agent's plan limit close to running out or used up, in words for the feed; null while it is fine. */
73
+ export function limitText(limit: RateLimit): string | null {
74
+ if (limit.status === 'ok') return null;
75
+ const used = limit.usedPercent === undefined ? '' : ` (${Math.round(limit.usedPercent)}% used)`;
76
+ const resets = limit.resetsAt === undefined ? '' : `, resets ${new Date(limit.resetsAt).toLocaleString()}`;
77
+ return `${limit.name ? `${limit.name} ` : ''}${limit.window} limit ${limit.status === 'exceeded' ? 'reached' : 'nearly used up'}${used}${resets}`;
78
+ }
79
+
64
80
  /** Turns a session snapshot into what the page may see: secrets blanked. */
65
81
  const safe = (state: SessionState): SessionState => JSON.parse(redact(JSON.stringify(state))) as SessionState;
66
82
 
@@ -109,6 +125,8 @@ class Talk {
109
125
  /** Tools announced already: a running tool is announced when it starts and must not be again once it settles. */
110
126
  private tools = new Set<string>();
111
127
  private ended = new Set<number>();
128
+ /** Plan limits announced already, by limit and state, so a warning shows once. */
129
+ private limits = new Set<string>();
112
130
 
113
131
  /**
114
132
  * The Studio's tools as the Studio saw them called (arguments, outcome). Some agents report a tool call by
@@ -214,6 +232,13 @@ class Talk {
214
232
 
215
233
  /** The feed (Activity, the terminal) gets the gist: what was said, each tool, refusals, how a turn ended. */
216
234
  private announce(state: SessionState) {
235
+ for (const limit of state.rateLimits ?? []) {
236
+ const key = `${limit.name ?? ''}/${limit.window}/${limit.status}`;
237
+ const text = limitText(limit);
238
+ if (!text || this.limits.has(key)) continue;
239
+ this.limits.add(key);
240
+ this.publish('limit', { text });
241
+ }
217
242
  for (const turn of state.turns) {
218
243
  if (!this.seen.has(turn.id)) {
219
244
  this.seen.set(turn.id, 0);
@@ -416,6 +441,7 @@ export class AgentHub {
416
441
  agent,
417
442
  cwd,
418
443
  model: agentModel(name),
444
+ budget: agentBudget(),
419
445
  task: {
420
446
  env: withoutProviderCredentials(withoutSecrets(process.env)) as Record<string, string>,
421
447
  // genaicode hands the server to Muse as -c overrides, the token through an environment variable, never argv.
@@ -1,6 +1,6 @@
1
1
  // The Muse Code panel: genaicode's own chat (genaicode/react), fed the session state the Studio server keeps
2
2
  // for this world. The page holds no agent and no key: it sends what the person types and answers approvals.
3
- import { AgentChat, type ChatNotice, type SessionState, type SessionTurn } from 'genaicode/react';
3
+ import { AgentChat, limitChips, type ChatNotice, type SessionState, type SessionTurn } from 'genaicode/react';
4
4
  import type { ComponentChildren } from 'preact';
5
5
  import { useEffect, useRef, useState } from 'preact/hooks';
6
6
  import { call, fileUrl, token } from './api.ts';
@@ -309,6 +309,8 @@ export function AgentPanel({ slug, replay, speed = 1, jobs = [], notices, afterT
309
309
  </select>
310
310
  )}
311
311
  <span class="grow" />
312
+ {/* The agent's plan limits and the conversation's budget, once near their end or reported. */}
313
+ {!replay && state && limitChips(state).map((chip) => <span key={chip.key} class="gc-chip gc-limit" data-level={chip.level} title={chip.title}>{chip.text}</span>)}
312
314
  {replay
313
315
  ? <span class="live on">{replayDone ? 'replay done' : 'replay'}</span>
314
316
  : <span class={`live ${live ? 'on' : ''}`}>{running ? 'working…' : live ? 'ready' : 'reconnecting'}</span>}
package/app/web/main.ts CHANGED
@@ -2935,9 +2935,15 @@ class Game {
2935
2935
  const img = c.getImageData(0, 0, w, h);
2936
2936
  const d = img.data;
2937
2937
  const ramp = (v: number, a: number, b: number) => Math.max(0, Math.min(1, (v - a) / (b - a)));
2938
+ // Only the flame's own pixels: near white-hot and strongly warm. Wood or paper lit warm by it (a letter by a
2939
+ // candle) passed a looser test, and the whole box flared over them as a bright square. Faded to nothing at
2940
+ // the box's edge, so what still passes never ends on a straight line.
2938
2941
  for (let i = 0; i < d.length; i += 4) {
2939
2942
  const luma = 0.3 * d[i] + 0.59 * d[i + 1] + 0.11 * d[i + 2];
2940
- d[i + 3] = Math.round(255 * ramp(luma, 140, 215) * ramp(d[i] - d[i + 2], 40, 100));
2943
+ const k = i / 4;
2944
+ const [u, v] = [((k % w) + 0.5) / w * 2 - 1, (Math.floor(k / w) + 0.5) / h * 2 - 1];
2945
+ const edge = Math.max(0, 1 - (u * u + v * v)) ** 0.5;
2946
+ d[i + 3] = Math.round(255 * ramp(luma, 170, 235) * ramp(d[i] - d[i + 2], 70, 130) * edge);
2941
2947
  }
2942
2948
  c.putImageData(img, 0, 0);
2943
2949
  return { canvas, x0, y0 };
package/docs/studio.md CHANGED
@@ -34,7 +34,7 @@ The agent can use `job_inspect` to see a job's timestamped recent activity, how
34
34
 
35
35
  Every fix is kept in a file next to what it fixes and applied again by every later build, so a rebuild never loses it: `places/<place>/corrections.json` (rebuilding only that place from the step it feeds), `story-fixes.json`, `cast/fixes.json`. A place's thing list is edited the same way: Remove takes a prop off it (kept in the picture, or painted out with one Muse edit) and Add makes something painted a thing of its own (`removed` and `added` in `corrections.json`, which every step reads with world.json); exits, items and people belong to the story. A small repaint rebuilds only what it reaches (`app/src/scene/carry.ts`): each place step keeps the painting it last ran on (`places/<place>/.ran/`), compares the new one with it tile by tile, and asks again only about what the changed tiles reach (a thing's or floor's mask, a plate, the backdrop's cards, the light, the stairs); everything else is asked about the old painting, which the memo answers for nothing, and the place keeps its scale. Lighting the lanterns of a harbour takes about 100 s instead of 270 s, and a rebuild on the same painting asks nothing new. A change over a third of the painting rebuilds everything. A place whose steps have not kept their painting (built before this, or put back by an Undo) takes the painting on disk as theirs before a repaint, so that repaint carries too. **History** lists the jobs the world went through, the agent's included, each with who ran it, when and which places it changed, and puts the world back as it was before any of them (Redo takes that back right after); a locked world or place is left alone by the agent.
36
36
 
37
- The conversation is genaicode's chat with Muse Code (default), Claude Code or Codex, picked in its header before the first message (Setup's **Default agent**, or a pick here, is remembered in `~/.config/diorama/studio.json` for new worlds; a world keeps the agent it last talked with, and `DIORAMA_AGENT=codex` overrides both; `DIORAMA_AGY_MODEL=gemini-4-argon-high`, `DIORAMA_CODEX_MODEL`, `DIORAMA_MUSE_MODEL`, `DIORAMA_AGENT_CLAUDE_MODEL` or `DIORAMA_AGENT_MODEL` picks the agent's model); after it, "Hand over…" passes the conversation to another agent and a line marks where it changed hands. After a Studio restart the earlier turns come back on screen from `studio/sessions/`. The conversation is also kept in words in `studio/conversation.md`: a new agent session (another agent, or after the Studio restarts) is told to read it first, so nothing agreed is lost. The agent works through the same actions as the page, so its steps show live and its changes survive rebuilds; when a build leaves places unplayable it is woken to repair them, three tries a place. Every conversation is recorded in `studio/sessions/`: open one from "Recorded sessions", or `/w/<slug>?replay=<file>&speed=2`, to play it back for a film.
37
+ The conversation is genaicode's chat with Muse Code (default), Claude Code or Codex, picked in its header before the first message (Setup's **Default agent**, or a pick here, is remembered in `~/.config/diorama/studio.json` for new worlds; a world keeps the agent it last talked with, and `DIORAMA_AGENT=codex` overrides both; `DIORAMA_AGY_MODEL=gemini-4-argon-high`, `DIORAMA_CODEX_MODEL`, `DIORAMA_MUSE_MODEL`, `DIORAMA_AGENT_CLAUDE_MODEL` or `DIORAMA_AGENT_MODEL` picks the agent's model); after it, "Hand over…" passes the conversation to another agent and a line marks where it changed hands. After a Studio restart the earlier turns come back on screen from `studio/sessions/`. The conversation is also kept in words in `studio/conversation.md`: a new agent session (another agent, or after the Studio restarts) is told to read it first, so nothing agreed is lost. The agent works through the same actions as the page, so its steps show live and its changes survive rebuilds; when a build leaves places unplayable it is woken to repair them, three tries a place. When the agent reports its plan limits (Claude Code's 5-hour and weekly limits, Codex's), the header shows how much is used and when it resets once a limit is near its end, and the terminal says so once. `DIORAMA_AGENT_MAX_COST` (dollars) and `DIORAMA_AGENT_MAX_TOKENS` cap what one world's conversation may spend: the header shows the budget, warns at 80%, and once it is used up a new message does not start. Every conversation is recorded in `studio/sessions/`: open one from "Recorded sessions", or `/w/<slug>?replay=<file>&speed=2`, to play it back for a film.
38
38
 
39
39
  **Setup** shows what this machine still needs, which agents it found, and the model calls sent across all worlds, priced once you enter the per-call prices from your billing pages. Keys are only ever typed in the terminal (`diorama setup`).
40
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtanczyk/diorama",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Turn a story prompt into playable painted point-and-click scenes with Meta Muse and SAM 3.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  "@meta-sam/parser": "^0.1.0",
55
55
  "@preact/signals": "^2.11.3",
56
56
  "esbuild": "^0.25.0",
57
- "genaicode": "^2.28.1",
57
+ "genaicode": "^2.29.0",
58
58
  "onnxruntime-web": "^1.31.1",
59
59
  "playwright": "^1.63.0",
60
60
  "preact": "^10.29.8",