@gtanczyk/diorama 0.1.0 → 0.3.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 (42) hide show
  1. package/README.md +1 -0
  2. package/app/src/actions/budget.ts +11 -3
  3. package/app/src/actions/cast.ts +19 -0
  4. package/app/src/actions/fix.ts +28 -10
  5. package/app/src/actions/index.ts +1 -0
  6. package/app/src/actions/language.ts +52 -0
  7. package/app/src/actions/world.ts +4 -3
  8. package/app/src/commands/backdrop.ts +23 -3
  9. package/app/src/commands/cast.ts +32 -13
  10. package/app/src/commands/export.ts +17 -0
  11. package/app/src/commands/index.ts +1 -0
  12. package/app/src/commands/language.ts +87 -0
  13. package/app/src/commands/play.ts +5 -1
  14. package/app/src/commands/sound.ts +16 -6
  15. package/app/src/meta/memo.ts +14 -0
  16. package/app/src/meta/voice.ts +2 -2
  17. package/app/src/play/geometry.ts +12 -2
  18. package/app/src/play/parallax.ts +2 -0
  19. package/app/src/scene/cast-fixes.ts +5 -3
  20. package/app/src/scene/cast.ts +16 -9
  21. package/app/src/scene/derive.ts +46 -1
  22. package/app/src/scene/held.ts +62 -8
  23. package/app/src/scene/layers.ts +52 -0
  24. package/app/src/scene/rig.ts +6 -2
  25. package/app/src/scene/shadows.ts +10 -2
  26. package/app/src/scene/verify.ts +12 -0
  27. package/app/src/scene/warp.ts +5 -3
  28. package/app/src/setup/checks.ts +2 -2
  29. package/app/src/speech/transcribe.ts +2 -2
  30. package/app/src/studio/agent.ts +10 -8
  31. package/app/src/studio/repair.ts +1 -1
  32. package/app/src/studio/server.ts +4 -2
  33. package/app/src/world/language.ts +116 -0
  34. package/app/studio/build.tsx +5 -3
  35. package/app/studio/main.tsx +3 -2
  36. package/app/web/living.ts +36 -0
  37. package/app/web/main.ts +83 -30
  38. package/docs/characters.md +1 -1
  39. package/docs/releasing.md +22 -0
  40. package/docs/scenes.md +1 -1
  41. package/docs/setup.md +1 -1
  42. package/package.json +3 -3
package/README.md CHANGED
@@ -113,6 +113,7 @@ Model answers are memoised by a hash of their inputs, so running a step again wi
113
113
  - [Characters, style and icons](docs/characters.md)
114
114
  - [NPC story stages, optional walks and travel](docs/npc-motion.md)
115
115
  - [Sound and voices](docs/sound.md)
116
+ - [Releasing and the changelog](docs/releasing.md) ([CHANGELOG.md](CHANGELOG.md))
116
117
 
117
118
  ## Built at the Meta AI Global Hackathon
118
119
 
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { z } from 'zod';
7
- import type { ModelName } from '../meta/memo.ts';
7
+ import { callVia, MUSE_VOICE, type ModelName } from '../meta/memo.ts';
8
8
  import { imageVia } from '../meta/muse.ts';
9
9
  import { sparkTier, sparkVia } from '../meta/spark.ts';
10
10
  import { configDir } from '../secrets.ts';
@@ -54,10 +54,18 @@ function tally(dir: string, since: string, credits?: string) {
54
54
  for (const line of fs.readFileSync(log, 'utf8').split('\n')) {
55
55
  if (!line) continue;
56
56
  try {
57
- const c = JSON.parse(line) as { at: string; model: ModelName; hit: boolean; usd?: number; via?: string };
57
+ const c = JSON.parse(line) as { at: string; model: ModelName; hit: boolean; hash?: string; usd?: number; via?: string };
58
58
  if (c.hit) continue;
59
+ const via = callVia(dir, c);
60
+ // Muse Voice transcribing a take: Meta's, not an ElevenLabs call, so the ElevenLabs price is not put on it.
61
+ if (via === MUSE_VOICE) {
62
+ usd.all += c.usd ?? 0;
63
+ if (c.at >= since) usd.recent += c.usd ?? 0;
64
+ if (credits && c.at >= credits) usd.sinceCredits += c.usd ?? 0;
65
+ continue;
66
+ }
59
67
  // Answered by another model (Claude, Gemini through OpenRouter): not on Meta's bill, counted apart.
60
- if (c.via && c.via !== 'contributor') {
68
+ if (via && via !== 'contributor') {
61
69
  usd.elsewhereCalls++;
62
70
  usd.elsewhereUsd += c.usd ?? 0;
63
71
  continue;
@@ -89,6 +89,8 @@ defineAction({
89
89
  looksNow: fixes[c.key]?.looks ?? c.looks,
90
90
  fix: fixes[c.key] ?? null,
91
91
  figure: pic(`cast/${c.key}.png`),
92
+ // What the cut-out keeps beyond the body (cast_holds) and the share of its height the body takes.
93
+ cut: fs.existsSync(files.path('cast', c.key, 'cut.json')) ? files.readJson(files.path('cast', c.key, 'cut.json')) : null,
92
94
  face: faceStatus(files, c.key),
93
95
  painting: pic(`cast/${c.key}-painting.png`),
94
96
  rig: animationCurrent(files,c.key,'rig') && fs.existsSync(files.path('cast', c.key, 'rig.json')),
@@ -152,6 +154,23 @@ defineAction({
152
154
  },
153
155
  });
154
156
 
157
+ defineAction({
158
+ name: 'cast_holds',
159
+ description: 'Keep what a character holds or carries on their cut-out (a giant slice of toast over the head, a lantern, a net), however big: SAM finds each '
160
+ + 'thing by its name and it is cut out with them and walks with their hands. Without holds, what their description says they hold is used (read by Spark); '
161
+ + 'holds names the things yourself in a few words each ("slice of toast"), [] keeps nothing extra. Kept across rebuilds. Cuts the figure again from its '
162
+ + 'painting and makes its 2D walk again: no Muse calls, a few SAM calls. Use it when the person says the figure lost what it holds. '
163
+ + 'A 3D model and its sprites never hold anything (held things tear its rig): when the held thing is who they are, say so. Then play_reload. Runs as a job.',
164
+ input: z.object({ slug: Slug, key: z.string().min(1), holds: z.array(z.string().min(2).max(60)).max(4).optional() }),
165
+ job: true,
166
+ async run({ slug, key, holds }, ctx) {
167
+ const prepare = prepareFix(ctx, slug, key, (f) => {
168
+ if (holds) f.holds = holds;
169
+ });
170
+ return ctx.jobs.start(slug, ['rig', slug, key], `cut out ${key} with what they hold`, ctx.by, prepare);
171
+ },
172
+ });
173
+
155
174
  defineAction({
156
175
  name: 'cast_animate',
157
176
  description: 'Give a character a 3D model now, also one the build skips (an NPC with no lines, a creature or an animated object): '
@@ -22,19 +22,11 @@ async function fixPlace(ctx: ActionContext, slug: string, place: string, from: '
22
22
  const dir = path.join(ctx.root, slug);
23
23
  const blocked = agentBlocked(dir, ctx.by, place);
24
24
  if (blocked) throw new ActionError(423, blocked);
25
- // The agent's repair limit: after three fixes in a row that left the place unplayable, it is the person's turn.
26
- if (ctx.by === 'agent') {
27
- const since = new Date(Date.now() - 30 * 60_000).toISOString();
28
- const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
29
- const lastOk = tries.findLastIndex((j) => j.state === 'done');
30
- if (tries.slice(lastOk + 1).filter((j) => j.state === 'failed').length >= 3) {
31
- throw new ActionError(429, `three fixes of ${place} in a row left it unplayable: stop here and tell the person what you tried and what you would do next`);
32
- }
33
- }
25
+ const note = ctx.by === 'agent' ? repairGuard(ctx, slug, place, label, from) : undefined;
34
26
  const placeDir = path.join(dir, 'places', place);
35
27
  const needs = { layout: null, paint: 'blockin.png', masks: 'painting.png', derive: 'masks.json' }[from];
36
28
  if (needs && !fs.existsSync(path.join(placeDir, needs))) throw new ActionError(404, `${slug}/${place} has no ${needs} yet: build it first`);
37
- return ctx.jobs.start(slug, ['build', slug, '--from', from, '--to', 'verify', '--place', place], `${label} · ${place}`, ctx.by, () => {
29
+ const job = ctx.jobs.start(slug, ['build', slug, '--from', from, '--to', 'verify', '--place', place], `${label} · ${place}`, ctx.by, () => {
38
30
  // The job snapshots first, then changes its source. Queued fixes must not change a running build.
39
31
  fs.mkdirSync(placeDir, { recursive: true });
40
32
  const fixes = readCorrections(placeDir);
@@ -42,6 +34,32 @@ async function fixPlace(ctx: ActionContext, slug: string, place: string, from: '
42
34
  writeCorrections(placeDir, Corrections.parse(fixes));
43
35
  fs.rmSync(path.join(placeDir, 'verify.json'), { force: true });
44
36
  }, addedThing ? { place, key: addedThing } : undefined);
37
+ return note ? { ...job, note } : job;
38
+ }
39
+
40
+ /** Fixes that repaint or relayout cost a Muse call; cheap ones only rerun SAM and the walk grid. */
41
+ const COSTLY = new Set(['layout', 'paint']);
42
+
43
+ /**
44
+ * The agent's repair guardrail. A streak of fixes that left the place unplayable does not stop the agent: it steers it.
45
+ * After three, a fix that already failed in the streak is refused (it would fail the same way) and a new path goes
46
+ * ahead with a short reminder; after six, costly repaints and relayouts wait for the person, while cheap fixes stay open.
47
+ */
48
+ function repairGuard(ctx: ActionContext, slug: string, place: string, label: string, from: string): string | undefined {
49
+ const since = new Date(Date.now() - 30 * 60_000).toISOString();
50
+ const tries = ctx.jobs.list(slug).filter((j) => j.by === 'agent' && j.args.includes('--place') && j.args[j.args.indexOf('--place') + 1] === place && (j.queuedAt ?? '') >= since);
51
+ const lastOk = tries.findLastIndex((j) => j.state === 'done');
52
+ const failed = tries.slice(lastOk + 1).filter((j) => j.state === 'failed');
53
+ if (failed.length < 3) return undefined;
54
+ const tried = [...new Set(failed.map((j) => j.label.replace(` · ${place}`, '')))];
55
+ const paths = 'other paths: mask_brush (paint or erase a thing\'s mask by hand), floor_cells (add or take back 60 px floor cells), stand_set or hero_start_set, thing_remove, corrections_clear of a fix that made it worse, or ask_person';
56
+ if (tried.includes(label)) {
57
+ throw new ActionError(429, `${place}: "${label}" already failed in this streak (${failed.length} fixes in a row left it unplayable: ${tried.join(', ')}). Do not repeat it; look with place_view (walk, masks) and take a different path. ${paths}.`);
58
+ }
59
+ if (failed.length >= 6 && COSTLY.has(from)) {
60
+ throw new ActionError(429, `${place}: ${failed.length} fixes in a row left it unplayable (${tried.join(', ')}). A repaint or relayout now costs credits on a guess: ask_person first, or keep to the cheap fixes. ${paths}.`);
61
+ }
62
+ return `${failed.length} fixes of ${place} in a row left it unplayable (${tried.join(', ')}); this one is a new path. Check place_status after it ends; if it fails too, tell the person briefly what you tried.`;
45
63
  }
46
64
 
47
65
  const thingOf = (c: Corrections, key: string) => (c.things[key] ??= { strokes: [] });
@@ -24,6 +24,7 @@ import './pictures.ts';
24
24
  import './start.ts';
25
25
  import './sound.ts';
26
26
  import './step-work.ts';
27
+ import './language.ts';
27
28
 
28
29
  import './video.ts';
29
30
  import './idea-review.ts';
@@ -0,0 +1,52 @@
1
+ // The language a world is told in: world_language translates every word the player reads or hears and has the
2
+ // voices cast and read again in it.
3
+ import fs from 'node:fs';
4
+ import { z } from 'zod';
5
+ import { credits } from '../sound/cost.ts';
6
+ import { elevenKey } from '../sound/sources.ts';
7
+ import { Story } from '../story/schema.ts';
8
+ import { agentBlocked } from '../studio/locks.ts';
9
+ import { WorldFiles } from '../world/files.ts';
10
+ import { languagePlan, planLine } from '../world/language.ts';
11
+ import { Slug } from '../world/schema.ts';
12
+ import { ActionError, defineAction } from './registry.ts';
13
+
14
+ defineAction({
15
+ name: 'world_language',
16
+ description: 'Tell the world in another language ("English", "Polish", "German"): the title, pitch, character and item names and every line of the story '
17
+ + '(intro, remarks, dialogue, answers, item names, endings) are translated in one cheap Spark pass, with names kept the same everywhere; the game\'s '
18
+ + 'own words (hints, goodbye, buttons) follow world.json\'s language by themselves. Keys, puzzles, paintings, masks and sounds stay. Then the voices are '
19
+ + 'cast again as native speakers of the new language (no accent of the old one), every spoken line is read again and the talking faces follow. '
20
+ + 'voices false translates only (cheap; the game shows text until run_step voices). Without go it only answers what it redoes and about what '
21
+ + 'it costs: tell the person, then call again with go true once they agree. Runs as a job; history_undo takes it back. Then play_reload.',
22
+ input: z.object({
23
+ slug: Slug,
24
+ language: z.string().trim().min(2).max(30).describe('The new language as its English name: "English", "Polish".'),
25
+ voices: z.boolean().optional().describe('false: translate the text only and leave the voices for later. Default true.'),
26
+ go: z.boolean().optional().describe('true runs it; leave out to get the estimate first.'),
27
+ }),
28
+ job: true,
29
+ async run({ slug, language, voices = true, go }, ctx) {
30
+ const files = new WorldFiles(slug, ctx.root);
31
+ if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
32
+ const name = language[0].toUpperCase() + language.slice(1);
33
+ const world = files.world();
34
+ const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
35
+ const plan = languagePlan(files, world, story, name);
36
+ if (!go) {
37
+ const left = await credits(elevenKey());
38
+ return {
39
+ estimate: plan,
40
+ line: voices ? planLine(plan) : `${plan.texts} texts translated from ${plan.from} to ${plan.to} in one Spark pass; voices left for later`,
41
+ ...(left && voices && { elevenLabsCreditsLeft: left.left }),
42
+ next: 'tell the person what it redoes and costs; call again with go: true when they agree',
43
+ };
44
+ }
45
+ const blocked = agentBlocked(files.dir, ctx.by);
46
+ if (blocked) throw new ActionError(423, blocked);
47
+ const busy = ctx.jobs.busy(slug);
48
+ if (busy) throw new ActionError(409, `${busy.label} is ${busy.state}; wait before changing the language`);
49
+ const job = ctx.jobs.start(slug, ['language', slug, name, ...(voices ? [] : ['--text-only'])], `language ${name}`, ctx.by);
50
+ return { job, line: voices ? planLine(plan) : 'text only' };
51
+ },
52
+ });
@@ -10,6 +10,7 @@ import { placeTrouble } from '../studio/trouble.ts';
10
10
  import { withThingEdits, WorldFiles } from '../world/files.ts';
11
11
  import { liveVoiceFailures } from '../sound/data.ts';
12
12
  import { Slug, World } from '../world/schema.ts';
13
+ import { callVia } from '../meta/memo.ts';
13
14
  import { storyNeeds } from '../story/check.ts';
14
15
  import { ActionError, defineAction } from './registry.ts';
15
16
 
@@ -57,9 +58,9 @@ export function callCounts(dir: string, byVia = false): Record<string, number> {
57
58
  for (const line of fs.readFileSync(log, 'utf8').split('\n')) {
58
59
  if (!line) continue;
59
60
  try {
60
- const call = JSON.parse(line) as { model: string; hit: boolean; via?: string };
61
- // byVia: "model|via", so Studio names who answered (Claude standing in for Spark) as the live counts do.
62
- const key = byVia ? `${call.model}|${typeof call.via === 'string' ? call.via : ''}` : call.model;
61
+ const call = JSON.parse(line) as { model: string; hit: boolean; hash?: string; via?: string };
62
+ // byVia: "model|via", so Studio names who answered (Claude standing in for Spark, Muse Voice transcribing) as the live counts do.
63
+ const key = byVia && !call.hit ? `${call.model}|${callVia(dir, call) ?? ''}` : call.model;
63
64
  if (!call.hit) counts[key] = (counts[key] ?? 0) + 1;
64
65
  } catch {}
65
66
  }
@@ -9,7 +9,7 @@ import { Geometry } from '../play/geometry.ts';
9
9
  import { defineCommand } from '../registry.ts';
10
10
  import { BACKDROP_GROW, backdropFor, smearedBackdrop, type Card } from '../scene/backdrop.ts';
11
11
  import { carryBetween, carryFrom, paintingFor, ran, remakes, tilesLine, withChange } from '../scene/carry.ts';
12
- import { cloudSheet, CRITTER, critterSheet, Depth, FALLING, fallSheet, farFlats, floatSheet, cloneFill, mistSheet, smokeSheet, waterSheet } from '../scene/layers.ts';
12
+ import { cloudSheet, CRITTER, critterSheet, Depth, FALLING, fallSheet, STREAM, streamSheet, farFlats, floatSheet, cloneFill, mistSheet, smokeSheet, waterSheet } from '../scene/layers.ts';
13
13
  import { maskFromPng as waterMaskOf } from '../image/mask.ts';
14
14
  import { WorldFiles } from '../world/files.ts';
15
15
 
@@ -80,8 +80,22 @@ defineCommand({
80
80
  png = await sharp(cloneFill(raw, FRAME.width, FRAME.height, dilate(critterMask, 3)), { raw: { width: FRAME.width, height: FRAME.height, channels: 3 } }).png().toBuffer();
81
81
  files.write(at('backdrop.png'), png);
82
82
  }
83
+ // Small things painted falling in a stream (code rain, snow, petals) fall on the page (layers/stream-<i>.png):
84
+ // cut out of the backdrop here, each column falling at its own pace. Other things' masks stay put (a doorway
85
+ // inside the rain keeps what is seen through it).
86
+ const looks = (k: string) => place.things.find((t) => t.key === k)?.looks ?? '';
87
+ const streamKeys = Object.keys(notes).filter((k) => !keys.includes(k) && !critterKeys.includes(k) && STREAM.test(`${k} ${notes[k].phrase} ${looks(k)}`) && fs.existsSync(at('masks', `${k}.png`)));
88
+ const streams: Array<{ png: Buffer; region: Buffer; box: [number, number, number, number] }> = [];
89
+ for (const k of streamKeys) {
90
+ const others = await Promise.all(Object.keys(notes).filter((o) => o !== k && fs.existsSync(at('masks', `${o}.png`))).map(async (o) => maskFromPng(fs.readFileSync(at('masks', `${o}.png`)))));
91
+ const made = await streamSheet(png, await maskFromPng(fs.readFileSync(at('masks', `${k}.png`))), dilate(unionAll(FRAME.width, FRAME.height, others), 2));
92
+ if (!made) continue;
93
+ png = made.backdrop;
94
+ streams.push(made);
95
+ }
96
+ if (streams.length) files.write(at('backdrop.png'), png);
83
97
  // Painted waterfalls pour (layers/fall-<i>.png); they stay out of the flat water below them.
84
- const fallKeys = Object.keys(notes).filter((k) => !keys.includes(k) && FALLING.test(`${k} ${notes[k].phrase}`) && fs.existsSync(at('masks', `${k}.png`)));
98
+ const fallKeys = Object.keys(notes).filter((k) => !keys.includes(k) && !streamKeys.includes(k) && FALLING.test(`${k} ${notes[k].phrase}`) && fs.existsSync(at('masks', `${k}.png`)));
85
99
  const fallMasks = await Promise.all(fallKeys.map(async (k) => maskFromPng(fs.readFileSync(at('masks', `${k}.png`)))));
86
100
  // The far view as flats one behind another (layers/), so it has depth of its own.
87
101
  fs.rmSync(at('layers'), { recursive: true, force: true });
@@ -133,6 +147,11 @@ defineCommand({
133
147
  const falls = (await Promise.all(fallMasks.map((m) => fallSheet(png, m)))).filter((f) => !!f);
134
148
  falls.forEach((f, i) => files.write(at('layers', `fall-${i}.png`), f.png));
135
149
  if (falls.length) console.log(` ${place.key}: ${falls.length} waterfall(s) pouring`);
150
+ streams.forEach((f, i) => {
151
+ files.write(at('layers', `stream-${i}.png`), f.png);
152
+ files.write(at('layers', `stream-${i}-region.png`), f.region);
153
+ });
154
+ if (streams.length) console.log(` ${place.key}: ${streams.length} stream(s) of small things falling (${streamKeys.join(', ')})`);
136
155
  const critters = critterKeys.length ? await critterSheet(withCritters, critterMask, wetMask) : [];
137
156
  critters.forEach((c, i) => files.write(at('layers', `critter-${i}.png`), c.png));
138
157
  if (critters.length) console.log(` ${place.key}: ${critters.length} painted animal(s) ${critters.some((c) => c.medium === 'air') ? 'flying and ' : ''}swimming (${critterKeys.join(', ')})`);
@@ -162,7 +181,7 @@ defineCommand({
162
181
  files.write(at('layers', 'mist-mask.png'), mist.mask);
163
182
  console.log(` ${place.key}: painted ${mist.phrase} drifting ${mist.drift === null ? 'with the wind' : mist.drift < 0 ? 'left' : 'right'}${mist.speed !== 1 ? ` x${mist.speed}` : ''}`);
164
183
  }
165
- if (far || water || mist || smoke.length || falls.length || critters.length || clouds) {
184
+ if (far || water || mist || smoke.length || falls.length || streams.length || critters.length || clouds) {
166
185
  if (far) {
167
186
  files.write(at('layers', 'sky.png'), far.sky);
168
187
  far.flats.forEach((f, i) => files.write(at('layers', `${i}.png`), f.png));
@@ -176,6 +195,7 @@ defineCommand({
176
195
  ...(smoke.length && { smoke: smoke.map((s, i) => ({ file: `layers/smoke-${i}.png`, box: s.box })) }),
177
196
  ...(floats.length && { floats: floats.map((s, i) => ({ file: `layers/float-${i}.png`, box: s.box })) }),
178
197
  ...(falls.length && { falls: falls.map((f, i) => ({ file: `layers/fall-${i}.png`, box: f.box })) }),
198
+ ...(streams.length && { streams: streams.map((f, i) => ({ file: `layers/stream-${i}.png`, region: `layers/stream-${i}-region.png`, box: f.box })) }),
179
199
  ...(critters.length && { critters: critters.map((c, i) => ({ file: `layers/critter-${i}.png`, box: c.box, medium: c.medium, facing: c.facing })) }),
180
200
  ...(clouds && { clouds: { box: clouds.box, clip: 'layers/cloud-clip.png', ...(clouds.keep && { keep: 'layers/cloud-keep.png' }), bands: clouds.bands.map((b, i) => ({ file: `layers/cloud-${i}.png`, box: b.box, speed: b.speed })) } }),
181
201
  });
@@ -6,8 +6,8 @@ import { museEdit } from '../meta/muse.ts';
6
6
  import { askSparkJson } from '../meta/spark.ts';
7
7
  import { z } from 'zod';
8
8
  import { defineCommand, takeOption } from '../registry.ts';
9
- import { castBodies, cutHero, paintFigure, readBodies, type Who } from '../scene/cast.ts';
10
- import { samFind } from '../meta/sam.ts';
9
+ import { castBodies, cutOut, findFigure, paintFigure, readBodies, type Who } from '../scene/cast.ts';
10
+ import { heldMask, heldThings } from '../scene/held.ts';
11
11
  import { photoStanding } from '../scene/photo.ts';
12
12
  import { rigFigure } from '../scene/rig.ts';
13
13
  import { checkSheet } from '../scene/rig-check.ts';
@@ -64,14 +64,15 @@ defineCommand({
64
64
  return null;
65
65
  });
66
66
  if (!made) return;
67
- const { painting, figure } = made;
67
+ const { painting, figure, share, holds } = made;
68
68
  bindExistingAnimation(files, who.key);
69
69
  files.write(files.path('cast', `${who.key}-painting.png`), painting);
70
70
  files.write(files.path('cast', `${who.key}.png`), figure);
71
+ writeCut(files, who.key, share, holds);
71
72
  markStyled(files, 'cast', who.key, style);
72
73
  // The same painting cut into parts, so the runtime can walk the figure.
73
74
  if (bodies[who.key]?.upright === false) return console.log(`${who.key}: ${files.path('cast', `${who.key}.png`)} · not upright: drawn as painted`);
74
- await rigCast(files, who.key, painting);
75
+ await rigCast(files, who.key, painting, holds);
75
76
  }));
76
77
  for (const m of missing) console.log(`set aside, shown as a stand-in: ${m}`);
77
78
  console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
@@ -83,9 +84,9 @@ defineCommand({
83
84
  * rig.json with the joints and how the walk measured. A walk that tears is kept but marked, and the game slides
84
85
  * the painted figure as one piece instead. Parts of an older cut-out rig are removed.
85
86
  */
86
- export async function rigCast(files: WorldFiles, key: string, painting: Buffer) {
87
+ export async function rigCast(files: WorldFiles, key: string, painting: Buffer, holds: string[] = []) {
87
88
  try {
88
- let { rig, parts, issues } = await rigFigure(files.memo, key, painting);
89
+ let { rig, parts, issues } = await rigFigure(files.memo, key, painting, 'warp', holds);
89
90
  rig.check = rig.sheet ? await checkSheet(rig, parts[rig.sheet.file]) : undefined;
90
91
  if (rig.check && !rig.check.ok) {
91
92
  // Try a separate neutral source for the warp; never overwrite the creator's painting.
@@ -96,7 +97,7 @@ export async function rigCast(files: WorldFiles, key: string, painting: Buffer)
96
97
  const verdict = schema.parse(await askSparkJson(files.memo, { label: `rig-neutral-check:${key}`, schema, images: [painting,neutral], personal: true,
97
98
  prompt: 'Compare the original and proposed neutral standing character. Check identity, clothing, colours and proportions match; legs are visible and not already walking; every object held in the original remains complete and held in the same hand. If none is held, heldPreserved is true. Reply JSON {"matches":true,"neutral":true,"heldPreserved":true,"reason":"short explanation"}.' }));
98
99
  if (!verdict.matches || !verdict.neutral || !verdict.heldPreserved) throw new Error(verdict.reason || 'neutral pose did not preserve the original');
99
- const candidate = await rigFigure(files.memo, key, neutral);
100
+ const candidate = await rigFigure(files.memo, key, neutral, 'warp', holds);
100
101
  candidate.rig.check = candidate.rig.sheet ? await checkSheet(candidate.rig, candidate.parts[candidate.rig.sheet.file]) : undefined;
101
102
  if (!candidate.rig.check?.ok) throw new Error(candidate.rig.check?.why ?? 'neutral walk did not pass');
102
103
  rig = candidate.rig; parts = candidate.parts; issues = [...issues, 'walk prepared from a verified neutral pose'];
@@ -121,22 +122,40 @@ export async function rigCast(files: WorldFiles, key: string, painting: Buffer)
121
122
 
122
123
  export const rigAttemptFile = (files: WorldFiles, key: string) => files.path('cast', key, 'rig-attempt.json');
123
124
 
125
+ /**
126
+ * cast/<key>/cut.json: what the cut-out keeps beyond the body (`holds`, SAM phrases) and the share of its height the
127
+ * body takes (`share`), so the game draws the body at its own height and a slice of toast held over it above.
128
+ */
129
+ export const cutFile = (files: WorldFiles, key: string) => files.path('cast', key, 'cut.json');
130
+ export function writeCut(files: WorldFiles, key: string, share: number, holds: string[]) {
131
+ files.write(cutFile(files, key), { share, holds });
132
+ }
133
+
124
134
  defineCommand({
125
135
  name: 'rig',
126
- usage: 'rig <slug> [key...] make the walk again from the cast paintings already painted (no Muse; SAM answers are kept), and cut the figure out again so what it holds stays on',
136
+ usage: 'rig <slug> [key...] make the walk again from the cast paintings already painted (no Muse; SAM answers are kept), and cut the figure out again so what it holds stays on (what the description says they hold, or cast/fixes.json holds)',
127
137
  async run(args) {
128
138
  const [slug, ...only] = args;
129
139
  if (!slug) throw new Error('usage: diorama rig <slug> [key...]');
130
140
  const files = new WorldFiles(slug);
141
+ const world = files.world();
131
142
  const bodies = readBodies(files, fs.existsSync);
143
+ const looksOf = new Map<string, string>([[world.hero.key, world.hero.looks], ...world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc').map((t) => [t.key, t.looks] as [string, string])), ...players(files).map((p) => [p.key, p.looks] as [string, string])]);
132
144
  const keys = fs.readdirSync(files.path('cast')).filter((f) => f.endsWith('-painting.png')).map((f) => f.slice(0, -'-painting.png'.length)).filter((k) => !only.length || only.includes(k));
133
145
  for (const key of keys) {
134
- if (bodies[key]?.upright === false) continue;
135
146
  const painting = fs.readFileSync(files.path('cast', `${key}-painting.png`));
136
- const person = (await samFind(files.memo, { label: `figure:${key}`, image: painting, prompt: 'person' })).sort((a, b) => b.mask.bits.reduce((s, x) => s + x, 0) - a.mask.bits.reduce((s, x) => s + x, 0))[0];
137
- if (person) files.write(files.path('cast', `${key}.png`), await cutHero(painting, person.mask));
138
- await rigCast(files, key, painting);
147
+ const who = fixedLooks(files, { key, looks: looksOf.get(key) ?? '' });
148
+ const holds = await heldThings(files.memo, who);
149
+ const person = await findFigure(files.memo, who, painting);
150
+ if (person) {
151
+ const { figure, share } = await cutOut(painting, person.mask, await heldMask(files.memo, key, painting, person.mask, holds));
152
+ files.write(files.path('cast', `${key}.png`), figure);
153
+ writeCut(files, key, share, holds);
154
+ console.log(`${key}: cut out again${holds.length ? `, holding ${holds.join(', ')}` : ''}${share < 1 ? ` (the body is ${Math.round(share * 100)}% of its height)` : ''}`);
155
+ }
156
+ if (bodies[key]?.upright === false) continue;
157
+ await rigCast(files, key, painting, holds);
139
158
  }
140
- console.log(`calls sent: sam ${files.memo.sent.sam}`);
159
+ console.log(`calls sent: spark ${files.memo.sent.spark}, sam ${files.memo.sent.sam}`);
141
160
  },
142
161
  });
@@ -7,6 +7,19 @@ import { defineCommand } from '../registry.ts';
7
7
  import { WorldFiles } from '../world/files.ts';
8
8
  import { bundle, WEB, worldData } from './play.ts';
9
9
 
10
+ /** A language name ("Polish", "polski") or code ("pl", "pt-BR") as a BCP 47 code, when Intl knows it. */
11
+ function langCode(language?: string): string | undefined {
12
+ if (!language) return undefined;
13
+ const t = language.trim();
14
+ if (/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/i.test(t)) return t;
15
+ const names = new Intl.DisplayNames(['en'], { type: 'language' });
16
+ for (const code of ['en', 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'cs', 'sk', 'uk', 'ru', 'sv', 'no', 'da', 'fi', 'hu', 'ro', 'el', 'tr', 'ja', 'ko', 'zh', 'ar', 'he', 'hi']) {
17
+ const own = new Intl.DisplayNames([code], { type: 'language' }).of(code);
18
+ if ([names.of(code), own].some((n) => n?.toLowerCase() === t.toLowerCase())) return code;
19
+ }
20
+ return undefined;
21
+ }
22
+
10
23
  const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
24
 
12
25
  /** The link preview (X, Facebook, chats): the world's title and pitch, and a 1200x630 picture: the world's own og.jpg when it has one, else its first painted place. */
@@ -77,6 +90,7 @@ export async function exportWorld(slug: string, out: string, home?: string, url?
77
90
  entry.layers.mist.mask = await copy(entry.layers.mist.mask);
78
91
  }
79
92
  for (const f of entry.layers.flats) f.file = await art(f.file); // transparent around the flat
93
+ for (const f of entry.layers.streams ?? []) [f.file, f.region] = [await art(f.file), await copy(f.region)];
80
94
  for (const f of [...(entry.layers.smoke ?? []), ...(entry.layers.floats ?? []), ...(entry.layers.falls ?? []), ...(entry.layers.critters ?? []), ...(entry.layers.clouds?.bands ?? [])]) f.file = await art(f.file);
81
95
  if (entry.layers.clouds) {
82
96
  entry.layers.clouds.clip = await copy(entry.layers.clouds.clip);
@@ -115,6 +129,9 @@ export async function exportWorld(slug: string, out: string, home?: string, url?
115
129
  page = page.replace('<body>', '<body data-shipped>');
116
130
  if (home) page = page.replace('<div id="soundless"', `<a id="home" href="${esc(home)}">← Diorama</a>\n<div id="soundless"`);
117
131
  page = page.replace('<title>Diorama</title>', await sharePreview(files, out, url));
132
+ // The page's language follows the world's, so browsers and screen readers read a Polish world as Polish.
133
+ const lang = langCode(files.world().language);
134
+ if (lang) page = page.replace('<html lang="en">', `<html lang="${lang}">`);
118
135
  fs.writeFileSync(path.join(out, 'index.html'), page);
119
136
  }
120
137
 
@@ -24,6 +24,7 @@ import './cast.ts';
24
24
  import './model.ts';
25
25
  import './sprites.ts';
26
26
  import './style.ts';
27
+ import './language.ts';
27
28
  import './play.ts';
28
29
  import './export.ts';
29
30
  import './publish.ts';
@@ -0,0 +1,87 @@
1
+ // diorama language <slug> <Language>: the world told in another language. Title, pitch, names and every line of
2
+ // the story are translated in one Spark pass; the paintings, masks, sounds and puzzles stay as they are. Then the
3
+ // voices are cast again for the new language (no accent of the old one) and every spoken line is read again,
4
+ // and the talking faces follow the new takes.
5
+ import fs from 'node:fs';
6
+ import { defineCommand, registry } from '../registry.ts';
7
+ import { castFixesFile, readCastFixes } from '../scene/cast-fixes.ts';
8
+ import { SoundPlan } from '../sound/plan.ts';
9
+ import { readStoryFixes, StoryFixes, storyFixesFile, textAt } from '../story/fixes.ts';
10
+ import { Story } from '../story/schema.ts';
11
+ import { WorldFiles } from '../world/files.ts';
12
+ import { applyWorldTexts, languagePlan, planLine, relanguage, translateTexts, worldTexts } from '../world/language.ts';
13
+ import { placeSources, soundPlanFrom } from './sound.ts';
14
+
15
+ const run = (name: string, args: string[]) => registry.get(name)!.run(args);
16
+
17
+ defineCommand({
18
+ name: 'language',
19
+ usage: 'language <slug> <Language> tell the world in another language ("English", "Polish"): title, pitch, names and the story translated in one Spark pass, then the voices cast again for that language and every spoken line read again [--text-only: leave the voices for later] [--estimate: only say what it redoes and costs]',
20
+ async run(args) {
21
+ const estimate = args.includes('--estimate');
22
+ const textOnly = args.includes('--text-only');
23
+ const [slug, language] = args.filter((a) => !a.startsWith('--'));
24
+ if (!slug || !language) throw new Error('usage: diorama language <slug> <Language> [--text-only] [--estimate]');
25
+ const files = new WorldFiles(slug);
26
+ const world = files.world();
27
+ const storyFile = files.path('story.json');
28
+ const story = fs.existsSync(storyFile) ? Story.parse(files.readJson(storyFile)) : null;
29
+ const plan = languagePlan(files, world, story, language);
30
+ console.log(`language ${slug}: ${planLine(plan)}`);
31
+ if (estimate) return;
32
+
33
+ if (!plan.same) {
34
+ const texts = worldTexts(world, story);
35
+ const kids = world.audience === 'kids' || fs.existsSync(files.path('drawings'));
36
+ console.log(`translating ${Object.keys(texts).length} texts from ${plan.from} to ${plan.to}`);
37
+ const translated = await translateTexts(files.memo, texts, plan.from, plan.to, kids);
38
+ const now = applyWorldTexts(world, story, translated, plan.to);
39
+ // sound.json is planned from the world and story: the words changed, not what makes a sound, so the plan stays.
40
+ const soundFile = files.path('sound.json');
41
+ const sound = fs.existsSync(soundFile) ? SoundPlan.safeParse(files.readJson(soundFile)) : undefined;
42
+ const sources = sound?.success ? placeSources(files, world) : undefined;
43
+ const soundWasCurrent = sound?.success && sources && sound.data.from === soundPlanFrom(world, story, sources);
44
+
45
+ files.write(files.path('world.json'), { ...JSON.parse(fs.readFileSync(files.path('world.json'), 'utf8')), ...now.world, ...(now.world.language ? {} : { language: undefined }) });
46
+ if (now.story) {
47
+ files.write(storyFile, now.story);
48
+ // The person's reworded lines are kept across rebuilds: they are reworded in the new language too.
49
+ const fixes = readStoryFixes(files);
50
+ if (Object.keys(fixes.texts).length) {
51
+ for (const path of Object.keys(fixes.texts)) {
52
+ const text = textAt(now.story, path);
53
+ if (text) fixes.texts[path] = { ...fixes.texts[path], text };
54
+ }
55
+ files.write(storyFixesFile(files), StoryFixes.parse(fixes));
56
+ }
57
+ }
58
+ // A voice the person described for the old language ("Native Polish speaker") is asked for in the new one.
59
+ const castFixes = readCastFixes(files);
60
+ let castChanged = false;
61
+ for (const fix of Object.values(castFixes)) {
62
+ if (fix.voice?.design && fix.voice.design !== relanguage(fix.voice.design, plan.from, plan.to)) {
63
+ fix.voice.design = relanguage(fix.voice.design, plan.from, plan.to);
64
+ castChanged = true;
65
+ }
66
+ }
67
+ if (castChanged) files.write(castFixesFile(files), castFixes);
68
+ if (soundWasCurrent && sound?.success && sources) files.write(soundFile, { ...sound.data, from: soundPlanFrom(files.world(), now.story, sources) });
69
+ for (const id of ['world.title', 'world.pitch']) console.log(` ${id}: ${translated[id]}`);
70
+ for (const [id, text] of Object.entries(translated).filter(([id]) => id.startsWith('names.'))) console.log(` ${id.slice(6)}: ${texts[id]} -> ${text}`);
71
+ console.log(`world.json and story.json are in ${plan.to} now`);
72
+ }
73
+ if (textOnly) return void console.log('voices left for later: run_step voices, then faces');
74
+ if (!story) return;
75
+ console.log('\n# voices');
76
+ await run('voices', [slug]);
77
+ if (fs.existsSync(files.path('cast'))) {
78
+ console.log('\n# faces');
79
+ try {
80
+ await run('build', [slug, '--from', 'faces', '--to', 'faces']);
81
+ } catch (error) {
82
+ // Talking mouths are polish: the voices are in, the game plays without them.
83
+ console.log(`faces not made again (${error instanceof Error ? error.message : error}): run_step faces later`);
84
+ }
85
+ }
86
+ },
87
+ });
@@ -65,6 +65,7 @@ export function worldData(files: WorldFiles) {
65
65
  ...(flats.smoke?.length && { smoke: flats.smoke.map((f) => ({ ...f, file: rel(f.file) })) }),
66
66
  ...(flats.floats?.length && { floats: flats.floats.map((f) => ({ ...f, file: rel(f.file) })) }),
67
67
  ...(flats.falls?.length && { falls: flats.falls.map((f) => ({ ...f, file: rel(f.file) })) }),
68
+ ...(flats.streams?.length && { streams: flats.streams.map((f) => ({ ...f, file: rel(f.file), region: rel(f.region) })) }),
68
69
  ...(flats.critters?.length && { critters: flats.critters.map((f) => ({ ...f, file: rel(f.file) })) }),
69
70
  ...(flats.clouds && { clouds: { ...flats.clouds, clip: rel(flats.clouds.clip), ...(flats.clouds.keep && { keep: rel(flats.clouds.keep) }), bands: flats.clouds.bands.map((b) => ({ ...b, file: rel(b.file) })) } }),
70
71
  };
@@ -118,7 +119,10 @@ export function worldData(files: WorldFiles) {
118
119
  const walks = body?.upright !== false;
119
120
  // A creature has no legs for a 2D rig, but its own 3D model (no skeleton, model.json creature) hops and squashes.
120
121
  const creatureModel = model?.info.creature ? model : undefined;
121
- return [key, { image: `cast/${f}`, ...(face && { face }), ...(body && { height: body.height }), ...(!walks && { upright: false }), ...(walks && rig && rig.check?.ok !== false ? { rig, parts: `cast/${key}` } : {}), ...(walks ? model && { model } : creatureModel && { model: creatureModel }), ...(walks && sheets && { sprites: sheets }) }];
122
+ // cast/<key>/cut.json (diorama cast, rig): the share of the cut-out's height the body takes when it holds something above it.
123
+ const cutAt = files.path('cast', key, 'cut.json');
124
+ const share = fs.existsSync(cutAt) ? Number((files.readJson(cutAt) as { share?: unknown }).share) : 1;
125
+ return [key, { image: `cast/${f}`, ...(share > 0 && share < 1 && { share }), ...(face && { face }), ...(body && { height: body.height }), ...(!walks && { upright: false }), ...(walks && rig && rig.check?.ok !== false ? { rig, parts: `cast/${key}` } : {}), ...(walks ? model && { model } : creatureModel && { model: creatureModel }), ...(walks && sheets && { sprites: sheets }) }];
122
126
  }));
123
127
  const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
124
128
  // changes/<i>.png per place: how story.changes[i] looks once it holds.
@@ -12,6 +12,20 @@ import { readSoundFixes } from '../sound/fixes.ts';
12
12
  import { elevenKey, musicgenPython } from '../sound/sources.ts';
13
13
  import { Story } from '../story/schema.ts';
14
14
  import { WorldFiles } from '../world/files.ts';
15
+ import type { World } from '../world/schema.ts';
16
+
17
+ /** What each place's sounds are planned from: its painting brief and floors. */
18
+ export function placeSources(files: WorldFiles, world: World): Record<string, PlaceSources> {
19
+ const sources: Record<string, PlaceSources> = {};
20
+ for (const place of world.places) {
21
+ const read = <T>(file: string, schema: { parse: (v: unknown) => T }) => (fs.existsSync(files.place(place.key, file)) ? schema.parse(files.readJson(files.place(place.key, file))) : undefined);
22
+ sources[place.key] = { brief: read('brief.json', Brief), layout: read('layout.json', Layout) };
23
+ }
24
+ return sources;
25
+ }
26
+
27
+ /** sound.json's `from`: the plan is made again when this changes. */
28
+ export const soundPlanFrom = (world: World, story: Story | null, sources: Record<string, PlaceSources>) => inputHash(JSON.stringify(world), JSON.stringify(story), JSON.stringify(sources));
15
29
 
16
30
  defineCommand({
17
31
  name: 'sound',
@@ -31,16 +45,12 @@ defineCommand({
31
45
  const files = new WorldFiles(slug);
32
46
  const world = files.world();
33
47
  const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
34
- const sources: Record<string, PlaceSources> = {};
35
- for (const place of world.places) {
36
- const read = <T>(file: string, schema: { parse: (v: unknown) => T }) => (fs.existsSync(files.place(place.key, file)) ? schema.parse(files.readJson(files.place(place.key, file))) : undefined);
37
- sources[place.key] = { brief: read('brief.json', Brief), layout: read('layout.json', Layout) };
38
- }
48
+ const sources = placeSources(files, world);
39
49
 
40
50
  // sound.json is the plan people may edit. It is made again with --replan, or when what it was made from
41
51
  // (world, story, briefs, floors) has changed since, so its shots and loops keep matching the story's things.
42
52
  const planFile = files.path('sound.json');
43
- const from = inputHash(JSON.stringify(world), JSON.stringify(story), JSON.stringify(sources));
53
+ const from = soundPlanFrom(world, story, sources);
44
54
  const old = fs.existsSync(planFile) ? SoundPlan.parse(files.readJson(planFile)) : undefined;
45
55
  let plan: SoundPlan;
46
56
  // A plan made before silent things lost their loops drops them here too, before anything is paid for.
@@ -23,6 +23,20 @@ export function inputHash(...parts: (string | Buffer)[]): string {
23
23
  /** What a call cost, when the asker knows: dollars and which backend answered (e.g. "claude" for a dev-mode Spark). */
24
24
  export type Paid = { data: Buffer; usd?: number; via?: string };
25
25
 
26
+ /** The `via` of a Muse Voice Transcribe call: it shares the voice memo with ElevenLabs speech, but Meta answers it. */
27
+ export const MUSE_VOICE = 'muse-voice';
28
+
29
+ /**
30
+ * Who answered a logged call. Transcripts logged before they carried `via` are told apart from speech by the answer
31
+ * kept in the world's memo (a transcript is .txt or .json, speech .mp3), so old worlds stop counting them as ElevenLabs.
32
+ */
33
+ export function callVia(dir: string, call: { model: string; hash?: string; via?: unknown }): string | undefined {
34
+ if (typeof call.via === 'string') return call.via;
35
+ if (call.model !== 'voice' || !call.hash) return undefined;
36
+ const at = (ext: string) => fs.existsSync(path.join(dir, 'memo', 'voice', `${call.hash}.${ext}`));
37
+ return at('txt') || at('json') ? MUSE_VOICE : undefined;
38
+ }
39
+
26
40
  export class Memo {
27
41
  /** Calls actually sent to each model in this process (cache hits are not counted). */
28
42
  readonly sent: Record<ModelName, number> = { spark: 0, muse: 0, sam: 0, sound: 0, music: 0, voice: 0, space: 0 };
@@ -1,6 +1,6 @@
1
1
  // Muse Voice Transcribe realtime transport. File uploads use voice-file.ts through speech/transcribe.ts.
2
2
  import { apiKey } from './http.ts';
3
- import { inputHash, type Memo } from './memo.ts';
3
+ import { inputHash, MUSE_VOICE, type Memo } from './memo.ts';
4
4
 
5
5
  export const VOICE_TRANSCRIBE = 'muse-voice-transcribe-1.0';
6
6
  const URL_ASR = 'wss://api.meta.ai/v1/asr/realtime';
@@ -29,7 +29,7 @@ export function transcribe(memo: Memo, label: string, pcm24k: Buffer, pace = 1):
29
29
  }
30
30
  });
31
31
  queue = run.catch(() => {});
32
- return run;
32
+ return run.then((data) => ({ data, via: MUSE_VOICE }));
33
33
  }).then((b) => b.toString('utf8'));
34
34
  }
35
35