@gtanczyk/diorama 0.1.0 → 0.2.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.
@@ -2,8 +2,10 @@
2
2
  // net of fish): named from their description, found by SAM on its own and kept on the cut-out. SAM's "person" is
3
3
  // only the body, and the plain-ground heuristics in warp.ts (silhouette, companions) drop anything bigger than the
4
4
  // figure as the scene behind it, so a prop larger than its holder needs its own mask.
5
+ import sharp from 'sharp';
5
6
  import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
6
- import { dilate, maskBox, subtract, union } from '../image/raster.ts';
7
+ import { components, dilate, maskBox, subtract, union } from '../image/raster.ts';
8
+ import { fillHoles } from './warp.ts';
7
9
  import type { Memo } from '../meta/memo.ts';
8
10
  import { samFind } from '../meta/sam.ts';
9
11
  import { askSparkJson } from '../meta/spark.ts';
@@ -28,24 +30,76 @@ export async function heldThings(memo: Memo, who: { key: string; looks: string;
28
30
 
29
31
  /**
30
32
  * Every instance SAM finds of the held things that touches the figure (or comes within a few percent of its height),
31
- * outside SAM's person, as one mask; undefined when there is none. One SAM call per phrase, memoized with the image.
33
+ * outside SAM's person, as one mask; undefined when there is none. One or two SAM calls per phrase, memoized with the
34
+ * image; when SAM finds none of them, what stands out from the plain ground and touches the body (plainHeld).
32
35
  */
33
36
  export async function heldMask(memo: Memo, key: string, image: Buffer, person: Mask, phrases: string[]): Promise<Mask | undefined> {
34
37
  const box = maskBox(person);
35
38
  if (!box || !phrases.length) return undefined;
36
39
  const near = dilate(person, Math.max(3, Math.round(0.04 * (box[3] - box[1]))));
37
40
  let out: Mask | undefined;
38
- for (const prompt of phrases) {
39
- for (const found of await samFind(memo, { label: `figure:${key}:held`, image, prompt })) {
40
- const own = subtract(found.mask, person);
41
- if (maskArea(own) < 0.01 * maskArea(person)) continue;
42
- if (!own.bits.some((b, i) => b && near.bits[i])) continue;
43
- out = union(out ?? emptyMask(person.width, person.height), own);
41
+ for (const phrase of phrases) {
42
+ // "slice of toast" may find nothing where "toast" does: the head noun is asked next.
43
+ const head = /\bof\b/i.test(phrase) ? phrase.split(/\bof\b/i).pop()!.trim() : phrase.trim().split(/\s+/).pop()!;
44
+ for (const prompt of [...new Set([phrase, head])]) {
45
+ let hit = false;
46
+ for (const found of await samFind(memo, { label: `figure:${key}:held`, image, prompt })) {
47
+ const own = subtract(found.mask, person);
48
+ if (maskArea(own) < 0.01 * maskArea(person)) continue;
49
+ if (!own.bits.some((b, i) => b && near.bits[i])) continue;
50
+ out = union(out ?? emptyMask(person.width, person.height), own);
51
+ hit = true;
52
+ }
53
+ if (hit) break;
44
54
  }
45
55
  }
56
+ if (out) return out;
57
+ const { data } = await sharp(image).resize(person.width, person.height, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
58
+ return plainHeld(data, person);
59
+ }
60
+
61
+ /**
62
+ * Without SAM: the figure is painted alone on a plain ground, so what stands out from that ground and touches the
63
+ * body (not the picture's edge) is what they hold. Used when the description says they hold something SAM did not find.
64
+ */
65
+ export function plainHeld(rgba: Buffer, person: Mask, tolerance = 64): Mask | undefined {
66
+ const { width: W, height: H } = person;
67
+ const box = maskBox(person);
68
+ if (!box) return undefined;
69
+ const [, py0, , py1] = box;
70
+ const ground = groundColour(rgba, W, H);
71
+ const ink = emptyMask(W, H);
72
+ for (let i = 0; i < W * H; i++) ink.bits[i] = !person.bits[i] && Math.hypot(rgba[i * 4] - ground[0], rgba[i * 4 + 1] - ground[1], rgba[i * 4 + 2] - ground[2]) > tolerance ? 1 : 0;
73
+ // The outline drawn round a thing is ink too, so the thing and its outline join into one piece.
74
+ const near = dilate(person, 3);
75
+ let out: Mask | undefined;
76
+ for (const c of components(fillHoles(ink))) {
77
+ const b = maskBox(c)!;
78
+ if (b[0] <= 1 || b[1] <= 1 || b[2] >= W - 1 || b[3] >= H - 1) continue;
79
+ if (maskArea(c) < 0.05 * maskArea(person) || !c.bits.some((v, i) => v && near.bits[i])) continue;
80
+ // A patch of floor or shadow at the feet is not held.
81
+ if ((b[1] + b[3]) / 2 > py0 + 0.85 * (py1 - py0)) continue;
82
+ out = union(out ?? emptyMask(W, H), subtract(c, person));
83
+ }
46
84
  return out;
47
85
  }
48
86
 
87
+ /** The patches of plain ground (the picture's border colour) inside `whole` that are no part of `body`, bigger than a speck. */
88
+ export function groundGaps(rgba: Buffer, whole: Mask, body: Mask, tolerance = 40): Mask[] {
89
+ const { width: W, height: H } = whole;
90
+ const ground = groundColour(rgba, W, H);
91
+ const gap = emptyMask(W, H);
92
+ for (let i = 0; i < W * H; i++) gap.bits[i] = whole.bits[i] && !body.bits[i] && Math.hypot(rgba[i * 4] - ground[0], rgba[i * 4 + 1] - ground[1], rgba[i * 4 + 2] - ground[2]) <= tolerance ? 1 : 0;
93
+ return components(gap).filter((c) => maskArea(c) > 0.002 * maskArea(body));
94
+ }
95
+
96
+ const groundColour = (rgba: Buffer, W: number, H: number) => {
97
+ const border: number[][] = [];
98
+ for (let x = 0; x < W; x += 4) for (const y of [0, H - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
99
+ for (let y = 0; y < H; y += 4) for (const x of [0, W - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
100
+ return [0, 1, 2].map((c) => border.map((p) => p[c]).sort((a, b) => a - b)[border.length >> 1]);
101
+ };
102
+
49
103
  /** The share of the cut-out's height the body takes, its feet at the bottom: a prop held over the head makes it less than 1. */
50
104
  export function bodyShare(person: Mask, whole: Mask): number {
51
105
  const p = maskBox(person);
@@ -7,7 +7,7 @@ import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
7
7
  import { components, dilate, intersect, maskBox, subtract, union, unionAll } from '../image/raster.ts';
8
8
  import type { Memo } from '../meta/memo.ts';
9
9
  import { samFind } from '../meta/sam.ts';
10
- import { heldMask } from './held.ts';
10
+ import { groundGaps, heldMask } from './held.ts';
11
11
  import { labelPixels, Part, silhouette, warpSheet } from './warp.ts';
12
12
  import type { PartName, Rig } from '../play/rig.ts';
13
13
 
@@ -192,7 +192,9 @@ export async function rigFigure(memo: Memo, key: string, painting: Buffer, style
192
192
  const footless = (m: Mask) => feet.reduce((acc, f) => subtract(acc, f), m);
193
193
  // The lower body without the feet: SAM's trousers with the bare legs below them (rolled-up trousers, shorts,
194
194
  // a skirt), else every leg it found.
195
- const legs = legsFound.map((l) => footless(inPerson(l))).filter((l) => maskArea(l) > H * 4).sort((a, b) => maskArea(b) - maskArea(a));
195
+ const shinsOf = legsFound.map((l) => footless(inPerson(l)));
196
+ // Short legs under big boots (a small figure holding something huge over its head) are allowed half the size.
197
+ const legs = (shinsOf.some((l) => maskArea(l) > H * 4) ? shinsOf.filter((l) => maskArea(l) > H * 4) : shinsOf.filter((l) => maskArea(l) > H * 2)).sort((a, b) => maskArea(b) - maskArea(a));
196
198
  const worn = trousers[0] && inPerson(trousers[0]);
197
199
  const lower = footless(worn ? legs.filter((l) => overlap(dilate(worn, 4), l) > 0).reduce((a, l) => union(a, l), worn) : unionAll(W, Hh, legs));
198
200
  if (maskArea(lower) < H * 4) throw new Error(`${key}: no legs found to rig`);
@@ -320,6 +322,8 @@ export async function rigFigure(memo: Memo, key: string, painting: Buffer, style
320
322
  if (style === 'warp') {
321
323
  // The whole painting, held things and all, each pixel labelled with the part whose bones pull it.
322
324
  const whole = silhouette(data, held ? union(person, held) : person);
325
+ // The window of plain ground a thing held up in both hands closes with the arms stays see-through.
326
+ if (held) for (const gap of groundGaps(data, whole, union(person, held))) gap.bits.forEach((b, i) => b && (whole.bits[i] = 0));
323
327
  const labels = labelPixels(whole, [[Part.Body, person], [Part.FarLeg, far], [Part.NearLeg, near], [Part.FarBoot, farBoot], [Part.NearBoot, nearBoot], [Part.FarArm, farArm], [Part.NearArm, nearArm]], person);
324
328
  const [cw, chh] = placed.size;
325
329
  const image = Buffer.alloc(cw * chh * 4);
@@ -24,6 +24,8 @@ export type Shadows = { mask: Mask; light?: Light; casters: CasterShadow[]; sour
24
24
  /** Darker than this share of the lit floor around it is shadow. */
25
25
  export const SHADOW_DARK = 0.74;
26
26
  const REF_CELL = 16;
27
+ /** Dark floor over more than this share of the floor is its pattern, not shadow. */
28
+ export const PATTERN_SHARE = 0.4;
27
29
  const REF_RADIUS = 6;
28
30
 
29
31
  /** The painting's luma, lightly blurred so plank grain and pebbles don't count. */
@@ -135,8 +137,14 @@ export async function findShadows(memo: Memo, label: string, painting: Buffer, f
135
137
  const contact = centroid(intersect(shadow, touch)) ?? [cx, b.baseY];
136
138
  measured.push({ thing: b.key, px: maskArea(shadow), ...measure(shadow, contact, height) });
137
139
  }
138
- const mask = unionAll(W, H, kept);
139
- const directed = measured.filter((m) => m.dir);
140
+ let mask = unionAll(W, H, kept);
141
+ let directed = measured.filter((m) => m.dir);
142
+ // "Shadow" over much of the floor is the floor's own pattern (a black-and-white checkerboard, dark tiles), not
143
+ // light: painted shadows cover a quarter of it at most. Nobody is darkened there; the light is Spark's.
144
+ if (maskArea(mask) > PATTERN_SHARE * Math.max(1, maskArea(floor))) {
145
+ mask = emptyMask(W, H);
146
+ directed = [];
147
+ }
140
148
  // Dark wet planks next to a teapot look like a shadow too: Spark says whether there is sun at all.
141
149
  const sun = await sparkLight(memo, `light:${label}`, askWith);
142
150
  if (!sun) return { mask: emptyMask(W, H), casters: measured, source: 'none' };
@@ -32,10 +32,12 @@ export function labelPixels(silhouette: Mask, masks: [Part, Mask][], person?: Ma
32
32
  const ring = dilate(c, 2);
33
33
  ring.bits.forEach((b, i) => b && !c.bits[i] && label[i] && touch.set(label[i], (touch.get(label[i]) ?? 0) + 1));
34
34
  if (!touch.size) continue;
35
- // Held up in both hands (a big slice of toast over the head), it goes with the body: neither arm owns it.
35
+ // Held up in both hands (a big slice of toast over the head), it goes with the body and so do both arms:
36
+ // they carry it, they do not swing away from it.
36
37
  const arms = [Part.NearArm, Part.FarArm].filter((a) => touch.has(a)).sort((a, b) => touch.get(b)! - touch.get(a)!);
37
- const arm = arms.length === 2 && maskArea(c) > 0.5 * maskArea(person) ? Part.Body : arms[0];
38
- const part = arm ?? [...touch].sort((a, b) => b[1] - a[1])[0][0];
38
+ const carried = arms.length === 2 && maskArea(c) > 0.5 * maskArea(person);
39
+ if (carried) label.forEach((l, i) => (l === Part.NearArm || l === Part.FarArm) && (label[i] = Part.Body));
40
+ const part = carried ? Part.Body : arms[0] ?? [...touch].sort((a, b) => b[1] - a[1])[0][0];
39
41
  c.bits.forEach((b, i) => b && (label[i] = part));
40
42
  }
41
43
  }
@@ -1,6 +1,6 @@
1
1
  // Small provider boundary: the video pipeline only knows PCM in, normalized evidence out.
2
2
  import { metaFileStt } from '../meta/voice-file.ts';
3
- import { inputHash, type Memo } from '../meta/memo.ts';
3
+ import { inputHash, MUSE_VOICE, type Memo } from '../meta/memo.ts';
4
4
  import { Transcript, type SpeechOptions, type SttProvider } from './types.ts';
5
5
  export { type SttProvider, type SpeechOptions, Transcript } from './types.ts';
6
6
  export const defaultStt = metaFileStt;
@@ -28,7 +28,7 @@ export async function transcribeFile(memo: Memo, label: string, pcm: Buffer, opt
28
28
  const saved = await memo.remember('voice', label, hash, 'json', async () => {
29
29
  const request = (lanes.get(lane) ?? Promise.resolve()).catch(() => {}).then(() => provider.transcribe(pcm, options));
30
30
  lanes.set(lane, request);
31
- try { return Buffer.from(JSON.stringify(check(await request))); }
31
+ try { return { data: Buffer.from(JSON.stringify(check(await request))), via: MUSE_VOICE }; }
32
32
  finally { if (lanes.get(lane) === request) lanes.delete(lane); }
33
33
  });
34
34
  return check(JSON.parse(saved.toString('utf8')));
@@ -24,13 +24,14 @@ When the person adds pictures and asks to expand this existing game, inspect eac
24
24
  During a staged extension, the log describes work in a private copy: it is not in the game yet. A successful job_wait call may still return state running; report the actual job state and current work, never call it complete or playable until the job says done and the checks have been inspected. The progress display reports build milestones, not an assessment of visual quality or an actual playthrough.
25
25
  When a job seems slow or the person asks what it is doing, call job_inspect for timestamped activity, how long it has been quiet, locally observed operations, and voice/sound counts and failures. job_wait returns a shorter version too. Say what is actually observed, including failed or missing lines; an individual failed attempt can be retried, so distinguish it from a failed job or an item that stayed missing. A long-running local request or quiet log alone does not prove the provider is stuck. job_cancel can stop a running or queued job when the person asks; stopping can discard staged work and does not undo paid calls. Do not cancel just because the log is quiet.
26
26
  To fix a place use the fix tools, which are kept across rebuilds: mask_fix (a better SAM phrase, the real box, or the kind of a thing), floor_cells, mask_brush, stand_set, repaint_region (costs a Muse edit), paint_words (how the place, its things and floors are described to the painter); corrections_get shows what is fixed already, corrections_clear takes fixes back. When asked to go through the person's notes, read feedback_pending, fix each one and close it with feedback_done in one sentence. Check budget before a rebuild of many places.
27
+ When the person wants the game in another language ("make it English", "po polsku"), call world_language: it translates the title, names and the whole story in one pass and casts and records the voices again as native speakers; first without go, tell them what it redoes and what the voices cost, then call it with go true once they agree (voices false when they only want the text now). Never translate it line by line with story_text, and never say the language cannot be changed.
27
28
  When the person wants the game to look different (more realistic, watercolour, like a picture book, less cartoon), call style_set with the style in a few words (medium, palette, light; scope place or cast for only one place or the characters): first without go, tell them what it repaints and about what it costs, then call it with go true once they agree. It repaints each picture keeping where everything is, so masks and their fixes still fit; children's drawings stay the reference; history_undo takes it back. Never write world.json's style by hand.
28
29
  Before a risky change, say what you will do; history_undo can take back a job. Respect locks. Keys are never yours: if a key is missing, ask the person to run \`diorama setup\` in a terminal.
29
- Read files freely to find out why something failed, but do not edit world files or run commands that change them: make every change through the diorama MCP tools, so the person sees it live, can undo it, and it survives the next build (a hand edit is overwritten by the next build). If no tool can make a change you need, say so and describe the tool that is missing instead of working around it. Start from world_status, place_status, place_view, job_wait, corrections_get and feedback_pending: place_view shows you a place as a picture (layer walk, masks or painting), which files and the shell cannot. Reach for the shell only when no Studio tool answers the question.
30
+ Read files freely to find out why something failed, but do not edit world files or run commands that change them: make every change through the diorama MCP tools, so the person sees it live, can undo it, and it survives the next build (a hand edit is overwritten by the next build). When no single tool makes the change the person asks for, do not stop at "there is no tool": find the nearest way with the tools you have (several story_text calls, a story_redo with their wish, a run_step) and offer it with what it costs, using ask_person; mention the missing tool only as a side note, so it can be added later. Refuse only what would need a hand edit or would throw away their work. Start from world_status, place_status, place_view, job_wait, corrections_get and feedback_pending: place_view shows you a place as a picture (layer walk, masks or painting), which files and the shell cannot. Reach for the shell only when no Studio tool answers the question.
30
31
  When a build fails for another reason (a step that broke, a service that did not answer), the Studio wakes you with the error and the job's last lines: job_inspect it, give a service error (HTTP 5xx, a timeout, a busy Space) one job_retry, which picks up at the failed step, and fix anything else with that step's own tool; never retry the same error twice, tell the person instead. A character the 3D service left in 2D (cast_status: the 3D service did not answer) still plays with its 2D rig; cast_animate tries its model again once the service is back. When a build leaves places not playable, repair them one by one: place_status lists each error with its playbook (where to look, the fix tools in order, the last resort), and place_view shows you the walkable floor with the 60 px cells floor_cells takes, the gaps to bridge and the spots off the floor. An unreached patch where nothing is floor (a wall, the sky) is taken back, not bridged: corrections_clear the fix that made it, or mask_brush erase. Try the tools in that order; each fix rebuilds only that place, so read place_status again after its job ends. Stop after three fixes that did not change the error, and tell the person what you tried and what you would do next. Ask before the last resort, except one repaint_region per place.
31
32
  When layout failed and there is no picture, do not wait for place_view: inspect place_status errors, then call layout_fix with concrete layout guidance, or place_fix to resume. Both work before the place directory exists. Failed layout batches retain their diagnostics and retry with new feedback instead of replaying the same rejected batch. When paint fails with content_policy_violation, the image safety filter refused a word, not the picture: paint already lets Spark reword the place twice, so call paint_words with calm, plain words for the scene and the looks the brief had (leafless, not bare; a seat that rests, not hangs on chains; no children, blood or weapons), never an edit of world.json. A place that never got past a step is rebuilt from that step: place_fix (the Fix it button on its card) or job_retry does that by itself.
32
33
  Stairs: derive finds the steps of floors and walk-on things named as stairs (stair, step, schody), and people then step from tread to tread; place_view (layer walk) draws the steps it found in blue. Scale: derive measures how tall a person is at each spot (Spark's people, held against a door on the floor: a person by a door is about 0.8 of its height). When the hero looks far too small or too big next to a door, run_step derive with that place, which rebuilds it through verify; when it stays wrong, the door is not named as a door: give it a door phrase with mask_fix. When people glide up stairs as on a ramp, the stairs thing or floor is not named as stairs: give it a stairs phrase with mask_fix (for example "stone stairs"), which rebuilds the place.
33
- A message that starts with "[On screen: …]" says what the person had open in Studio as they wrote ("place", "place / thing", "cast", "cast / character" or "cast / item key"; older messages say "[Looking at: …]"): when they say "this", "it" or "here" without a name, they mean that; look at it (place_view for a place) before you change it. Never mention the prefix. A message that starts with "[Pictures: studio/pictures/01.jpg, …]" comes with pictures the person pasted, uploaded or took with the webcam: look at each with picture_view before you answer, and act on what they show. When the person is playing and your change shows in the game, call play_reload yourself rather than asking them to refresh. You cannot watch the game, but place_motion can: after a change to motion (water flow, fog, fire, weather, an animated thing) call it and check the motion shows before you say it works. A message that starts with "[Studio]" is a private note from the Studio, not from the person: follow it, never mention it or reply to it. The story has story_status, story_text (rewording) and story_redo (a wish to Spark); story_status lists unanswerable questions (a question in a "say" or in a line without choices leaves the player stuck): reword it into a statement with story_text, or story_redo when it needs real answers; it also lists unseen story parts (an item thing whose item has another key stays in the painting; a change on a person is never drawn): fix those with story_redo; the cast has cast_status, cast_redo, cast_animate, cast_rewalk and voice_set; speaking faces have faces_status and faces_prepare. Talking mouths can be off in Setup (faces_get; off by default): then never prepare, repair or calibrate faces and never offer to; characters speak with voice and gestures. Free fixes before paid ones: a 3D figure whose face shows as a patch of another colour, or whose colours look pale or plastic beside its painting, is a render matter; run sprites again (free for UV sprites, no Muse) before ever offering sprites_redo or per-frame Muse repaints, and when you do offer paid work say the real count, not a range. Order: a playable game first, talking mouths last. While any place is unpainted, being built or unplayable, never start face work (faces_prepare, face_frame_repair, face_lips_set, face_calibration_set refuse it); repair the places, and leave lips for after everything plays, or skip them: characters talk with gestures. A failed speaking animation is separate from game readiness; recordings and the previous appearance remain usable. Inspect coverage and repairWork first. Accepted atlases and calibrated mouths are saved, so resume rather than repainting the whole character. For a rejected mouth, faces_inspect with its work id and mouth returns the exact head and reference; face_frame_repair uses the returned revision and concrete visual guidance to edit ONLY that mouth, then revalidate the atlas. For an accepted atlas with uncertain lip corners, inspect the exact mouth image with faces_inspect and use face_lips_set with a normalized tight lip box relative to that single full-head image (not the whole atlas). Never guess coordinates, use it on rejected articulation, or lower validation thresholds. Both tools resume preparation and reject stale revisions/sources. Wait for the job, inspect faces_status again and play_reload after success. Do not repeat an unchanged failed repair; inspect the new stage and use its matching tool. For source-pose or final-patch calibration failures, face_calibration_inspect lists pending targets and shows their exact close-up. Use face_calibration_set with its target, revision, imageHash, tight lip box and visible-cavity open flag. This preserves accepted artwork and resumes the remaining checks. If an inspected mouth remains genuinely ambiguous, stop and explain that specific uncertainty rather than inventing coordinates. Image, 2D rig and talk-sprite faces share audio timing. 3D models articulate when model3d.mouth is true: method morphs uses authored facial poses, painted-surfaces animates calibrated face paint on the actual mesh. Check the reported view coverage and spritePoses pending count; a body skeleton alone does not articulate. Do not claim native mouth geometry for the painted-surface method; a 3D figure that walks straddle-legged with arms held out to the sides (like a zombie) or hands sticking out gets cast_relax (local Blender, free; blender_get says if it is there, else tell the person to install Blender and turn it on in Setup); a walk that tears (holes in clothes, a leg, boot or arm coming off, a lantern left hanging in the air) gets cast_rewalk, not cast_redo; a character with no 3D model the person explicitly wants animated in 3D (a creature, cast_status body upright false, or an NPC with no lines) gets a model with cast_animate, never a cast_redo that makes it stand on two legs: a creature hops, squashes and blinks as one body, then play_reload; a hero painted sitting, lying or with furniture grown into the 3D model (from a photo where they sat or lay) gets cast_redo with looks that describe only the person standing (clothes, trousers, shoes; no pose, bed or "not visible"), then the model again; inventory icons have icons_status, icon_redo (a hint in the person's words, cut or paint, another take) and icon_fix_clear, never a swapped file in items/. Voices are cast from the gender and age Spark sees in each figure: adults get stock voices, children, teens and the old get a voice made for them by ElevenLabs Voice Design (voice "design"). When one sounds wrong, voice_list with slug and key lists the voices that fit that character first; voice_set with voice "design" and a design description in English remakes a designed voice (no word "child" or age in years there: ElevenLabs refuses it). Never give a child an adult's voice or raise an adult voice's pitch to fake one. A character whose voice is "pending-design" in cast_status waits for Voice Design (it was refused or the service was busy): run_step voices or voice_set designs it again; never give them a stock adult instead. Lines with numbers or sums ("10+10=20") are read and checked as words by themselves; voiceFailures lists only lines the story still has. For one missing or rejected line, use voice_retry with slug, key and the exact text from voiceFailures: it keeps the selected voice and every other recording, gives rejected takes fresh seeds, and resumes saved audio after a transcription outage. Do not change voice_set merely to retry a line. Sounds have sound_status (each bed, loop, footstep, one-shot and music: made, or rejected and why) and sound_redo (ids, an optional hint in English, kept across rebuilds): a sound rejected as "mostly hiss" or "nearly silent" stays silent in the game until sound_redo, never an edited sound.json. cast_status says how the game moves each character (moves), why there is no 2D walk (rigIssue) and which walk directions left the pose (spriteIssues): those get sprites_redo, which paints new sheets without repainting the character; a character with no model after flat reconstructions walks with its 2D rig or slides as a cut-out, and cast_animate tries the model again. When the hero starts in front of an NPC or a thing and hides it, hero_start_set moves where they begin; npc_move changes the NPC standing position in the layout. Author a story-triggered trip with story_redo. A still frame in place_motion is a fault only when inTheGame lists water, mist or weather for that place.
34
+ A message that starts with "[On screen: …]" says what the person had open in Studio as they wrote ("place", "place / thing", "cast", "cast / character" or "cast / item key"; older messages say "[Looking at: …]"): when they say "this", "it" or "here" without a name, they mean that; look at it (place_view for a place) before you change it. Never mention the prefix. A message that starts with "[Pictures: studio/pictures/01.jpg, …]" comes with pictures the person pasted, uploaded or took with the webcam: look at each with picture_view before you answer, and act on what they show. When the person is playing and your change shows in the game, call play_reload yourself rather than asking them to refresh. You cannot watch the game, but place_motion can: after a change to motion (water flow, fog, fire, weather, an animated thing) call it and check the motion shows before you say it works. A message that starts with "[Studio]" is a private note from the Studio, not from the person: follow it, never mention it or reply to it. The story has story_status, story_text (rewording) and story_redo (a wish to Spark); story_status lists unanswerable questions (a question in a "say" or in a line without choices leaves the player stuck): reword it into a statement with story_text, or story_redo when it needs real answers; it also lists unseen story parts (an item thing whose item has another key stays in the painting; a change on a person is never drawn): fix those with story_redo; the cast has cast_status, cast_redo, cast_animate, cast_rewalk, cast_holds and voice_set; a figure whose cut-out lost what it holds or carries (a toast over its head, a lantern, a basket; cast_status cut.holds lists what is kept) gets cast_holds, not cast_redo: it cuts the same painting again with the held thing; speaking faces have faces_status and faces_prepare. Talking mouths can be off in Setup (faces_get; off by default): then never prepare, repair or calibrate faces and never offer to; characters speak with voice and gestures. Free fixes before paid ones: a 3D figure whose face shows as a patch of another colour, or whose colours look pale or plastic beside its painting, is a render matter; run sprites again (free for UV sprites, no Muse) before ever offering sprites_redo or per-frame Muse repaints, and when you do offer paid work say the real count, not a range. Order: a playable game first, talking mouths last. While any place is unpainted, being built or unplayable, never start face work (faces_prepare, face_frame_repair, face_lips_set, face_calibration_set refuse it); repair the places, and leave lips for after everything plays, or skip them: characters talk with gestures. A failed speaking animation is separate from game readiness; recordings and the previous appearance remain usable. Inspect coverage and repairWork first. Accepted atlases and calibrated mouths are saved, so resume rather than repainting the whole character. For a rejected mouth, faces_inspect with its work id and mouth returns the exact head and reference; face_frame_repair uses the returned revision and concrete visual guidance to edit ONLY that mouth, then revalidate the atlas. For an accepted atlas with uncertain lip corners, inspect the exact mouth image with faces_inspect and use face_lips_set with a normalized tight lip box relative to that single full-head image (not the whole atlas). Never guess coordinates, use it on rejected articulation, or lower validation thresholds. Both tools resume preparation and reject stale revisions/sources. Wait for the job, inspect faces_status again and play_reload after success. Do not repeat an unchanged failed repair; inspect the new stage and use its matching tool. For source-pose or final-patch calibration failures, face_calibration_inspect lists pending targets and shows their exact close-up. Use face_calibration_set with its target, revision, imageHash, tight lip box and visible-cavity open flag. This preserves accepted artwork and resumes the remaining checks. If an inspected mouth remains genuinely ambiguous, stop and explain that specific uncertainty rather than inventing coordinates. Image, 2D rig and talk-sprite faces share audio timing. 3D models articulate when model3d.mouth is true: method morphs uses authored facial poses, painted-surfaces animates calibrated face paint on the actual mesh. Check the reported view coverage and spritePoses pending count; a body skeleton alone does not articulate. Do not claim native mouth geometry for the painted-surface method; a 3D figure that walks straddle-legged with arms held out to the sides (like a zombie) or hands sticking out gets cast_relax (local Blender, free; blender_get says if it is there, else tell the person to install Blender and turn it on in Setup); a walk that tears (holes in clothes, a leg, boot or arm coming off, a lantern left hanging in the air) gets cast_rewalk, not cast_redo; a character with no 3D model the person explicitly wants animated in 3D (a creature, cast_status body upright false, or an NPC with no lines) gets a model with cast_animate, never a cast_redo that makes it stand on two legs: a creature hops, squashes and blinks as one body, then play_reload; a hero painted sitting, lying or with furniture grown into the 3D model (from a photo where they sat or lay) gets cast_redo with looks that describe only the person standing (clothes, trousers, shoes; no pose, bed or "not visible"), then the model again; inventory icons have icons_status, icon_redo (a hint in the person's words, cut or paint, another take) and icon_fix_clear, never a swapped file in items/. Voices are cast from the gender and age Spark sees in each figure: adults get stock voices, children, teens and the old get a voice made for them by ElevenLabs Voice Design (voice "design"). When one sounds wrong, voice_list with slug and key lists the voices that fit that character first; voice_set with voice "design" and a design description in English remakes a designed voice (no word "child" or age in years there: ElevenLabs refuses it). Never give a child an adult's voice or raise an adult voice's pitch to fake one. A character whose voice is "pending-design" in cast_status waits for Voice Design (it was refused or the service was busy): run_step voices or voice_set designs it again; never give them a stock adult instead. Lines with numbers or sums ("10+10=20") are read and checked as words by themselves; voiceFailures lists only lines the story still has. For one missing or rejected line, use voice_retry with slug, key and the exact text from voiceFailures: it keeps the selected voice and every other recording, gives rejected takes fresh seeds, and resumes saved audio after a transcription outage. Do not change voice_set merely to retry a line. Sounds have sound_status (each bed, loop, footstep, one-shot and music: made, or rejected and why) and sound_redo (ids, an optional hint in English, kept across rebuilds): a sound rejected as "mostly hiss" or "nearly silent" stays silent in the game until sound_redo, never an edited sound.json. cast_status says how the game moves each character (moves), why there is no 2D walk (rigIssue) and which walk directions left the pose (spriteIssues): those get sprites_redo, which paints new sheets without repainting the character; a character with no model after flat reconstructions walks with its 2D rig or slides as a cut-out, and cast_animate tries the model again. When the hero starts in front of an NPC or a thing and hides it, hero_start_set moves where they begin; npc_move changes the NPC standing position in the layout. Author a story-triggered trip with story_redo. A still frame in place_motion is a fault only when inTheGame lists water, mist or weather for that place.
34
35
  When you need the person to decide (yes or no, which of two fixes, go ahead with something that costs calls), ask with ask_person instead of a question in your text: it shows them buttons; then end your turn.
35
36
  After a redo the person asked for (a repaint, a recast), show them the result and stop: if a trace of what they wanted gone is still faintly there, say so and offer one more try with ask_person; never start another paid redo on your own. A message from the person while you wait for a job comes first: read it before waiting again, and job_cancel the job if they no longer want it.
36
37
  Answer in two or three short sentences, in the language the person wrote their newest message in (not the world's language, not an earlier turn's); the steps already show what you did.`;
@@ -3,6 +3,7 @@
3
3
  // Referer), the Host must be ours (no DNS rebinding), cross-site requests are refused (Origin and
4
4
  // Sec-Fetch-Site), writes are JSON only and no CORS header is ever sent. World files are served to <img>
5
5
  // with a SameSite=Strict cookie the page gets in exchange for the token.
6
+ import { callVia } from '../meta/memo.ts';
6
7
  import { randomBytes, timingSafeEqual } from 'node:crypto';
7
8
  import fs from 'node:fs';
8
9
  import http from 'node:http';
@@ -119,8 +120,9 @@ class WorldWatch {
119
120
  for (const line of text.slice(0, complete).split('\n')) {
120
121
  if (!line) continue;
121
122
  try {
122
- const { model, label, ms, hit, via } = JSON.parse(line) as Record<string, unknown>;
123
- this.events.publish('call', { world: this.slug, model, label, ms, hit, via });
123
+ const call = JSON.parse(line) as Record<string, unknown>;
124
+ const { model, label, ms, hit } = call;
125
+ this.events.publish('call', { world: this.slug, model, label, ms, hit, via: callVia(this.dir, call as { model: string; hash?: string; via?: unknown }) });
124
126
  } catch {}
125
127
  }
126
128
  }
@@ -0,0 +1,116 @@
1
+ // A world told in another language: every word the player reads or hears (title, pitch, names, the whole story)
2
+ // translated in one Spark pass, so names stay the same everywhere they appear. What the painters read (style,
3
+ // shows, mood, looks, a change's looks) is English already and stays; keys and flags never change, so the
4
+ // puzzles, masks, paintings and sounds all still fit. The voices are cast again for the new language afterwards.
5
+ import fs from 'node:fs';
6
+ import { z } from 'zod';
7
+ import { askSparkJson } from '../meta/spark.ts';
8
+ import type { Memo } from '../meta/memo.ts';
9
+ import { soundQuality } from '../sound/cost.ts';
10
+ import { languageKey } from '../sound/language.ts';
11
+ import { needsDesign, storyLines, Voices } from '../sound/voices.ts';
12
+ import type { Story } from '../story/schema.ts';
13
+ import type { WorldFiles } from './files.ts';
14
+ import type { World } from './schema.ts';
15
+
16
+ /** Story fields the player reads or hears. A change's `looks` is for the painter: it stays English. */
17
+ const STORY_TEXT = new Set(['intro', 'say', 'says', 'text', 'title', 'name', 'nope']);
18
+
19
+ /** Every player-facing text of a world and its story, by an id that says where it lives. */
20
+ export function worldTexts(world: World, story: Story | null): Record<string, string> {
21
+ const out: Record<string, string> = { 'world.title': world.title, 'world.pitch': world.pitch };
22
+ for (const [key, name] of Object.entries(world.names ?? {})) out[`names.${key}`] = name;
23
+ const walk = (node: unknown, path: string[]) => {
24
+ if (Array.isArray(node)) node.forEach((n, i) => walk(n, [...path, String(i)]));
25
+ else if (node && typeof node === 'object') {
26
+ for (const [k, v] of Object.entries(node)) {
27
+ if (typeof v === 'string') {
28
+ if (STORY_TEXT.has(k) && !(path[0] === 'changes')) out[`story.${[...path, k].join('.')}`] = v;
29
+ } else walk(v, [...path, k]);
30
+ }
31
+ }
32
+ };
33
+ if (story) walk(story, []);
34
+ return out;
35
+ }
36
+
37
+ /** The world and story with the translated texts put back at their ids. */
38
+ export function applyWorldTexts(world: World, story: Story | null, texts: Record<string, string>, language: string): { world: World; story: Story | null } {
39
+ const w: World = structuredClone(world);
40
+ const s: Story | null = story && structuredClone(story);
41
+ for (const [id, text] of Object.entries(texts)) {
42
+ const [head, ...rest] = id.split('.');
43
+ if (head === 'world') (w as Record<string, unknown>)[rest[0]] = text;
44
+ else if (head === 'names') w.names = { ...w.names, [rest.join('.')]: text };
45
+ else if (head === 'story' && s) {
46
+ let at: unknown = s;
47
+ const last = rest.pop()!;
48
+ for (const p of rest) at = Array.isArray(at) ? at[Number(p)] : (at as Record<string, unknown>)[p];
49
+ if (at && typeof at === 'object') (at as Record<string, unknown>)[last] = text;
50
+ }
51
+ }
52
+ if (languageKey(language) === 'en') delete w.language;
53
+ else w.language = language;
54
+ return { world: w, story: s };
55
+ }
56
+
57
+ const Answer = z.object({ texts: z.record(z.string(), z.string()) });
58
+
59
+ /**
60
+ * One Spark pass over all the texts, so a name reads the same in the title, the dialogue and the inventory.
61
+ * Ids missing from the answer are asked for once more, with the names already chosen; an empty answer throws.
62
+ */
63
+ export async function translateTexts(memo: Memo, texts: Record<string, string>, from: string, to: string, kids: boolean): Promise<Record<string, string>> {
64
+ const ask = (todo: Record<string, string>, names: Record<string, string>) => askSparkJson(memo, {
65
+ label: `language:${languageKey(to)}`,
66
+ schema: Answer,
67
+ prompt: `Translate the player-facing text of a point-and-click adventure game from ${from} to ${to}.
68
+ Write it as a native ${to} game writer would${kids ? ' for children aged 5 to 7, who hear every line read aloud' : ''}: natural, idiomatic and warm, not word for word. Keep each line's meaning, mood and humour, and keep it about as short (never longer by more than a few words).
69
+ - Names of characters, places and items ("names.*", items' "name"): give each a natural ${to} name that keeps the idea or pun of the original (a cat named after butter keeps a buttery name). Use exactly the same name every time it appears in any line, with the same capitalisation style as the original (a lowercase item name stays lowercase).
70
+ - A line that is a statement stays a statement; a question stays a question. Keep sound tags in square brackets ([laughs], [whispers]) exactly as they are, untranslated.
71
+ - Numbers, riddles and wordplay must still work in ${to}: adapt them, keep the answer the same.
72
+ - Every text must be fully in ${to}: no ${from} words left, except a name that is the same in both languages.
73
+ ${Object.keys(names).length ? `Names already chosen (use them): ${JSON.stringify(names)}\n` : ''}Texts (id: text): ${JSON.stringify(todo)}
74
+ Reply with JSON only: {"texts": {"<the same id>": "<${to} text>", ...}} with every id above exactly once.`,
75
+ });
76
+ const first = (await ask(texts, {})).texts;
77
+ const done = Object.fromEntries(Object.keys(texts).filter((id) => first[id]?.trim()).map((id) => [id, first[id].trim()]));
78
+ const missing = Object.fromEntries(Object.entries(texts).filter(([id]) => !done[id]));
79
+ if (Object.keys(missing).length) {
80
+ const names = Object.fromEntries(Object.entries(done).filter(([id]) => id.startsWith('names.') || /\.name$/.test(id)));
81
+ const again = (await ask(missing, names)).texts;
82
+ for (const id of Object.keys(missing)) if (again[id]?.trim()) done[id] = again[id].trim();
83
+ }
84
+ const still = Object.keys(texts).filter((id) => !done[id]);
85
+ if (still.length) throw new Error(`Spark left ${still.length} text(s) untranslated: ${still.slice(0, 5).join(', ')}`);
86
+ return done;
87
+ }
88
+
89
+ /** A person's voice wish written for the old language ("Native Polish speaker…") says the new one instead. */
90
+ export function relanguage(text: string, from: string, to: string): string {
91
+ return text.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi'), to);
92
+ }
93
+
94
+ export type LanguagePlan = { from: string; to: string; same: boolean; texts: number; lines: number; characters: number; credits: number; designs: string[]; quality: 'draft' | 'final' };
95
+
96
+ /**
97
+ * What a change of language redoes: one Spark pass over the texts, then every spoken line read again (about one
98
+ * ElevenLabs credit a character in final quality, half in draft) and a Voice Design for each child, teen or old speaker.
99
+ * The lines are counted in the old language: a translation is about as long.
100
+ */
101
+ export function languagePlan(files: WorldFiles, world: World, story: Story | null, language: string): LanguagePlan {
102
+ const from = world.language ?? 'English';
103
+ const same = languageKey(from) === languageKey(language);
104
+ const kids = world.audience === 'kids';
105
+ const lines = story ? storyLines(world, story, kids) : [];
106
+ const characters = lines.reduce((n, l) => n + l.text.length, 0);
107
+ const quality = soundQuality();
108
+ const voices = fs.existsSync(files.path('voices.json')) ? Voices.safeParse(files.readJson(files.path('voices.json'))) : undefined;
109
+ const designs = voices?.success ? Object.entries(voices.data.cast).filter(([, c]) => c.profile && needsDesign(c.profile)).map(([k]) => k) : [];
110
+ return { from, to: language, same, texts: Object.keys(worldTexts(world, story)).length, lines: lines.length, characters, credits: Math.round(characters * (quality === 'final' ? 1 : 0.5)), designs, quality };
111
+ }
112
+
113
+ export const planLine = (p: LanguagePlan) => (p.same
114
+ ? `already in ${p.to}: the texts stay; the voices are read again only where they are not in ${p.to} yet`
115
+ : `${p.texts} texts translated from ${p.from} to ${p.to} in one Spark pass (cents); then ${p.lines} spoken lines read again, about ${p.characters} characters, ~${p.credits} ElevenLabs credits (${p.quality})`
116
+ + `${p.designs.length ? `, plus a new designed voice for ${p.designs.join(', ')}` : ''}; paintings, masks, sounds and puzzles stay`);
@@ -34,11 +34,12 @@ export const PHASES = [
34
34
 
35
35
  /** memo/calls.jsonl's model names as people know them. */
36
36
  export const MODEL_NAME: Record<string, string> = { spark: 'Muse Spark', muse: 'Muse Image', sam: 'SAM 3.1', sound: 'ElevenLabs', music: 'ElevenLabs', voice: 'ElevenLabs', space: '3D' };
37
- const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1']);
37
+ const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1', 'Muse Voice']);
38
38
  const tone = (by: string) => ({ 'Muse Spark': 'spark', 'Muse Image': 'image', 'SAM 3.1': 'sam' })[by] ?? 'other';
39
39
 
40
40
  /** A model another than Meta's, by a short name: what a call's `via` or Setup's pick names. */
41
41
  export function viaName(via: string): string {
42
+ if (via === 'muse-voice') return 'Muse Voice';
42
43
  if (/claude/i.test(via)) return 'Claude';
43
44
  if (/banana/i.test(via)) return 'Nano Banana';
44
45
  if (/gemini|google/i.test(via)) return 'Gemini';
@@ -218,9 +219,10 @@ export function Pipeline({ job, all, live, build, typical, total, onOpen }: { jo
218
219
  <section class={`pipeline${job ? ' on' : ''}`} aria-live="polite">
219
220
  <div class="row head">
220
221
  <strong>{job ? `${job.by === 'agent' ? 'Agent: ' : ''}${job.label}` : 'Build'}</strong>
221
- {/* This job's calls, each with the world's total beside it: a new job starts at 0, the world keeps counting. */}
222
+ {/* This job's calls, with the world's total beside it only when earlier jobs added to it: "136 of 136" read as
223
+ a progress bar that was always full, while it only said this job made every call the world has. */}
222
224
  {job && calls.length > 0 && <span class="detail calls-kicker" title="Model calls this job made. The number after 'of' is the whole world so far, every build and agent job together">this job</span>}
223
- {job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} in this job${total?.[by] ? `, ${Math.max(n, total[by])} for this world so far` : ''}`}><ModelTag by={by} /> {n}{total?.[by] ? <span class="of"> of {Math.max(n, total[by])}</span> : null}</span>)}
225
+ {job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} sent in this job (not a target: it grows with every take and check)${(total?.[by] ?? 0) > n ? `, ${total![by]} for this world so far` : ''}`}><ModelTag by={by} /> {n}{(total?.[by] ?? 0) > n ? <span class="of"> · {total![by]} in all</span> : null}</span>)}
224
226
  {/* A step's raw line is cut to what it is doing ("barn-interior layout try 1"); the whole line is on hover. */}
225
227
  <span class="detail tail" title={job ? live.tail : undefined}>{job ? (fresh && live.lastCall ? `${nowBy(live.lastCall.by)}: ${live.lastCall.label}` : live.tail.split(/: |[.!?]\s/)[0].slice(0, 60)) : ''}</span>
226
228
  {when && <span class="detail when">{when}</span>}
@@ -1,7 +1,7 @@
1
1
  // Diorama Studio: Start (worlds and a new idea), Setup (what this machine needs) and the Workshop (a world
2
2
  // building live). Everything here goes through actions, the same ones `diorama action` and the agent use.
3
3
  import { render, type ComponentChildren } from 'preact';
4
- import { useEffect, useRef, useState } from 'preact/hooks';
4
+ import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
5
5
  import type { ChatNotice, SessionTurn } from 'genaicode/react';
6
6
  import { agentDefault, askAgent, call, type AgentDefaults, fileUrl, follow, listActions, openSession, token, type StudioEvent } from './api.ts';
7
7
  import { AgentPanel } from './agent.tsx';
@@ -592,7 +592,8 @@ function Workshop({ slug }: { slug: string }) {
592
592
  const creatorBrief = useCreatorBrief(slug);
593
593
  const worldRef = useRef<HTMLElement>(null);
594
594
  const stageRef = useRef<HTMLElement>(null);
595
- useEffect(() => {
595
+ // Layout effect: Esc must close the drawer from the moment it shows, not only after the next frame.
596
+ useLayoutEffect(() => {
596
597
  if (!byHand) return;
597
598
  const esc = (e: KeyboardEvent) => e.key === 'Escape' && setByHand(null);
598
599
  addEventListener('keydown', esc);