@gtanczyk/diorama 0.0.0-stage → 0.1.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 (299) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +152 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +248 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +31 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/models.ts +64 -0
  20. package/app/src/actions/motion.ts +119 -0
  21. package/app/src/actions/pictures.ts +49 -0
  22. package/app/src/actions/reactions.ts +36 -0
  23. package/app/src/actions/registry.ts +101 -0
  24. package/app/src/actions/share.ts +32 -0
  25. package/app/src/actions/sound.ts +73 -0
  26. package/app/src/actions/start.ts +154 -0
  27. package/app/src/actions/step-work.ts +150 -0
  28. package/app/src/actions/story.ts +101 -0
  29. package/app/src/actions/studio.ts +133 -0
  30. package/app/src/actions/style.ts +72 -0
  31. package/app/src/actions/video.ts +46 -0
  32. package/app/src/actions/view.ts +109 -0
  33. package/app/src/actions/weather.ts +38 -0
  34. package/app/src/actions/world.ts +289 -0
  35. package/app/src/commands/action.ts +39 -0
  36. package/app/src/commands/backdrop.ts +188 -0
  37. package/app/src/commands/browsers.ts +21 -0
  38. package/app/src/commands/build.ts +503 -0
  39. package/app/src/commands/cast.ts +142 -0
  40. package/app/src/commands/clip.ts +431 -0
  41. package/app/src/commands/derive.ts +167 -0
  42. package/app/src/commands/doctor.ts +74 -0
  43. package/app/src/commands/explore.ts +123 -0
  44. package/app/src/commands/export.ts +142 -0
  45. package/app/src/commands/extend.ts +75 -0
  46. package/app/src/commands/faces.ts +80 -0
  47. package/app/src/commands/icons.ts +69 -0
  48. package/app/src/commands/index.ts +36 -0
  49. package/app/src/commands/layout.ts +31 -0
  50. package/app/src/commands/masks.ts +117 -0
  51. package/app/src/commands/mcp.ts +52 -0
  52. package/app/src/commands/model.ts +284 -0
  53. package/app/src/commands/paint.ts +182 -0
  54. package/app/src/commands/plates.ts +97 -0
  55. package/app/src/commands/play.ts +192 -0
  56. package/app/src/commands/playthrough.ts +111 -0
  57. package/app/src/commands/publish.ts +78 -0
  58. package/app/src/commands/reactions.ts +40 -0
  59. package/app/src/commands/reel.ts +46 -0
  60. package/app/src/commands/setup.ts +85 -0
  61. package/app/src/commands/sound.ts +90 -0
  62. package/app/src/commands/sprites.ts +232 -0
  63. package/app/src/commands/story.ts +37 -0
  64. package/app/src/commands/studio.ts +65 -0
  65. package/app/src/commands/style.ts +87 -0
  66. package/app/src/commands/verify.ts +45 -0
  67. package/app/src/commands/voice-retry.ts +14 -0
  68. package/app/src/commands/voices.ts +145 -0
  69. package/app/src/commands/weather.ts +52 -0
  70. package/app/src/commands/world.ts +104 -0
  71. package/app/src/film/clip.ts +386 -0
  72. package/app/src/film/reel.ts +126 -0
  73. package/app/src/film/screencast.ts +123 -0
  74. package/app/src/image/mask.ts +29 -0
  75. package/app/src/image/overlay.ts +38 -0
  76. package/app/src/image/raster.ts +224 -0
  77. package/app/src/main.ts +28 -0
  78. package/app/src/meta/choice.ts +27 -0
  79. package/app/src/meta/http.ts +99 -0
  80. package/app/src/meta/memo.ts +91 -0
  81. package/app/src/meta/muse.ts +70 -0
  82. package/app/src/meta/openrouter.ts +93 -0
  83. package/app/src/meta/prices.ts +25 -0
  84. package/app/src/meta/sam.ts +83 -0
  85. package/app/src/meta/schema.ts +63 -0
  86. package/app/src/meta/spark.ts +220 -0
  87. package/app/src/meta/step.ts +14 -0
  88. package/app/src/meta/voice-file.ts +78 -0
  89. package/app/src/meta/voice.ts +103 -0
  90. package/app/src/model/animation.ts +38 -0
  91. package/app/src/model/blender/relax.py +284 -0
  92. package/app/src/model/blender.ts +115 -0
  93. package/app/src/model/build.ts +50 -0
  94. package/app/src/model/face.ts +21 -0
  95. package/app/src/model/fit.ts +181 -0
  96. package/app/src/model/glb.ts +241 -0
  97. package/app/src/model/plan.ts +151 -0
  98. package/app/src/model/quality.ts +48 -0
  99. package/app/src/model/space.ts +168 -0
  100. package/app/src/model/sprites.ts +293 -0
  101. package/app/src/model/views.ts +163 -0
  102. package/app/src/play/creature.ts +43 -0
  103. package/app/src/play/depth-occlusion.ts +150 -0
  104. package/app/src/play/exit-outline.ts +48 -0
  105. package/app/src/play/face3d.ts +34 -0
  106. package/app/src/play/focus.ts +78 -0
  107. package/app/src/play/geometry.ts +272 -0
  108. package/app/src/play/gestures.ts +23 -0
  109. package/app/src/play/glow.ts +187 -0
  110. package/app/src/play/lipsync.ts +69 -0
  111. package/app/src/play/living.ts +83 -0
  112. package/app/src/play/outline-path.ts +130 -0
  113. package/app/src/play/parallax.ts +200 -0
  114. package/app/src/play/path.ts +262 -0
  115. package/app/src/play/preview.ts +71 -0
  116. package/app/src/play/reactions.ts +92 -0
  117. package/app/src/play/rig.ts +172 -0
  118. package/app/src/play/shadow.ts +253 -0
  119. package/app/src/play/sound.ts +51 -0
  120. package/app/src/play/weather.ts +58 -0
  121. package/app/src/qa/explore.ts +136 -0
  122. package/app/src/qa/probe.ts +139 -0
  123. package/app/src/qa/route.ts +118 -0
  124. package/app/src/registry.ts +23 -0
  125. package/app/src/scene/animation-source.ts +36 -0
  126. package/app/src/scene/assets.ts +37 -0
  127. package/app/src/scene/autofix.ts +133 -0
  128. package/app/src/scene/backdrop.ts +326 -0
  129. package/app/src/scene/blockin.ts +189 -0
  130. package/app/src/scene/brief.ts +74 -0
  131. package/app/src/scene/carry.ts +177 -0
  132. package/app/src/scene/cast-fixes.ts +31 -0
  133. package/app/src/scene/cast.ts +192 -0
  134. package/app/src/scene/corrections.ts +164 -0
  135. package/app/src/scene/critic.ts +169 -0
  136. package/app/src/scene/depthmap.ts +149 -0
  137. package/app/src/scene/derive.ts +475 -0
  138. package/app/src/scene/diagnose.ts +132 -0
  139. package/app/src/scene/face-calibration.ts +65 -0
  140. package/app/src/scene/face-registration.ts +21 -0
  141. package/app/src/scene/face-work.ts +71 -0
  142. package/app/src/scene/faces-data.ts +48 -0
  143. package/app/src/scene/faces-settings.ts +20 -0
  144. package/app/src/scene/faces.ts +435 -0
  145. package/app/src/scene/glows.ts +153 -0
  146. package/app/src/scene/held.ts +55 -0
  147. package/app/src/scene/icons.ts +185 -0
  148. package/app/src/scene/layers.ts +822 -0
  149. package/app/src/scene/layout.ts +216 -0
  150. package/app/src/scene/locate.ts +118 -0
  151. package/app/src/scene/maskcheck.ts +43 -0
  152. package/app/src/scene/masks.ts +392 -0
  153. package/app/src/scene/occluders.ts +123 -0
  154. package/app/src/scene/occlusion-check.ts +138 -0
  155. package/app/src/scene/paint-layout.ts +135 -0
  156. package/app/src/scene/paint.ts +81 -0
  157. package/app/src/scene/photo.ts +123 -0
  158. package/app/src/scene/plates.ts +256 -0
  159. package/app/src/scene/playbook.ts +121 -0
  160. package/app/src/scene/preview.ts +23 -0
  161. package/app/src/scene/reactions.ts +118 -0
  162. package/app/src/scene/restyle.ts +53 -0
  163. package/app/src/scene/rig-check.ts +68 -0
  164. package/app/src/scene/rig.ts +375 -0
  165. package/app/src/scene/scale.ts +148 -0
  166. package/app/src/scene/shadows.ts +207 -0
  167. package/app/src/scene/stairs.ts +183 -0
  168. package/app/src/scene/status.ts +32 -0
  169. package/app/src/scene/verify.ts +156 -0
  170. package/app/src/scene/walk.ts +326 -0
  171. package/app/src/scene/warp.ts +417 -0
  172. package/app/src/scene/weather.ts +118 -0
  173. package/app/src/scene/words.ts +65 -0
  174. package/app/src/secrets.ts +219 -0
  175. package/app/src/setup/checks.ts +110 -0
  176. package/app/src/sound/cost.ts +74 -0
  177. package/app/src/sound/data.ts +89 -0
  178. package/app/src/sound/fixes.ts +23 -0
  179. package/app/src/sound/issues.ts +21 -0
  180. package/app/src/sound/language.ts +11 -0
  181. package/app/src/sound/lipsync-make.ts +98 -0
  182. package/app/src/sound/lipsync.ts +24 -0
  183. package/app/src/sound/make.ts +229 -0
  184. package/app/src/sound/musicgen.py +20 -0
  185. package/app/src/sound/pcm.ts +280 -0
  186. package/app/src/sound/plan.ts +154 -0
  187. package/app/src/sound/retry.ts +43 -0
  188. package/app/src/sound/settings.ts +28 -0
  189. package/app/src/sound/sources.ts +81 -0
  190. package/app/src/sound/spoken.ts +70 -0
  191. package/app/src/sound/voices.ts +579 -0
  192. package/app/src/speech/transcribe.ts +38 -0
  193. package/app/src/speech/types.ts +18 -0
  194. package/app/src/story/check.ts +230 -0
  195. package/app/src/story/design.ts +108 -0
  196. package/app/src/story/engine.ts +233 -0
  197. package/app/src/story/fixes.ts +54 -0
  198. package/app/src/story/npc-guidance.ts +7 -0
  199. package/app/src/story/phrases.ts +4 -0
  200. package/app/src/story/schema.ts +92 -0
  201. package/app/src/studio/agent.ts +554 -0
  202. package/app/src/studio/discovery.ts +28 -0
  203. package/app/src/studio/events.ts +48 -0
  204. package/app/src/studio/history.ts +124 -0
  205. package/app/src/studio/jobs.ts +291 -0
  206. package/app/src/studio/locks.ts +33 -0
  207. package/app/src/studio/mcp.ts +86 -0
  208. package/app/src/studio/operation.ts +18 -0
  209. package/app/src/studio/repair.ts +31 -0
  210. package/app/src/studio/server.ts +421 -0
  211. package/app/src/studio/trouble.ts +48 -0
  212. package/app/src/world/design.ts +72 -0
  213. package/app/src/world/drawings.ts +198 -0
  214. package/app/src/world/extend-stage.ts +109 -0
  215. package/app/src/world/extend.ts +123 -0
  216. package/app/src/world/extension-progress.ts +10 -0
  217. package/app/src/world/files.ts +62 -0
  218. package/app/src/world/idea-review.ts +32 -0
  219. package/app/src/world/photo-limits.ts +2 -0
  220. package/app/src/world/pool.ts +41 -0
  221. package/app/src/world/schedule.ts +64 -0
  222. package/app/src/world/schema.ts +74 -0
  223. package/app/src/world/sketch.ts +81 -0
  224. package/app/src/world/style-plan.ts +90 -0
  225. package/app/src/world/style-reference.ts +16 -0
  226. package/app/src/world/styled.ts +51 -0
  227. package/app/src/world/told.ts +105 -0
  228. package/app/src/world/video-limits.ts +3 -0
  229. package/app/src/world/video.ts +98 -0
  230. package/app/studio/activity.tsx +124 -0
  231. package/app/studio/agent.tsx +384 -0
  232. package/app/studio/api.ts +111 -0
  233. package/app/studio/atelier.tsx +234 -0
  234. package/app/studio/audio-missing.tsx +25 -0
  235. package/app/studio/audio-setup.tsx +33 -0
  236. package/app/studio/blender-setup.tsx +30 -0
  237. package/app/studio/build.tsx +677 -0
  238. package/app/studio/camera.tsx +59 -0
  239. package/app/studio/cast-work.tsx +207 -0
  240. package/app/studio/cast.tsx +178 -0
  241. package/app/studio/concept-edit.tsx +47 -0
  242. package/app/studio/creator-idea.tsx +46 -0
  243. package/app/studio/custom-look.tsx +36 -0
  244. package/app/studio/faces-setup.tsx +28 -0
  245. package/app/studio/favicon.svg +11 -0
  246. package/app/studio/figure.tsx +314 -0
  247. package/app/studio/fonts/OFL.txt +93 -0
  248. package/app/studio/fonts/fonts.css +7 -0
  249. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  250. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  251. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  252. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  253. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  254. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  255. package/app/studio/icons.tsx +42 -0
  256. package/app/studio/idea-card.tsx +42 -0
  257. package/app/studio/idea-review.tsx +181 -0
  258. package/app/studio/idea-storyboard.tsx +134 -0
  259. package/app/studio/index.html +15 -0
  260. package/app/studio/job-words.ts +33 -0
  261. package/app/studio/lightbox.tsx +49 -0
  262. package/app/studio/main.tsx +1328 -0
  263. package/app/studio/nav.ts +14 -0
  264. package/app/studio/place.tsx +264 -0
  265. package/app/studio/review-sketch.tsx +47 -0
  266. package/app/studio/say-it.tsx +49 -0
  267. package/app/studio/step-work.tsx +134 -0
  268. package/app/studio/story.tsx +394 -0
  269. package/app/studio/style-menu.tsx +81 -0
  270. package/app/studio/style.css +1390 -0
  271. package/app/studio/sure.tsx +54 -0
  272. package/app/studio/tip.tsx +74 -0
  273. package/app/studio/tsconfig.json +5 -0
  274. package/app/studio/video.tsx +73 -0
  275. package/app/studio/world.tsx +188 -0
  276. package/app/tsconfig.json +13 -0
  277. package/app/web/audio.ts +315 -0
  278. package/app/web/face.ts +131 -0
  279. package/app/web/face3d.ts +35 -0
  280. package/app/web/glows.ts +387 -0
  281. package/app/web/hero3d.ts +728 -0
  282. package/app/web/index.html +118 -0
  283. package/app/web/living.ts +404 -0
  284. package/app/web/main.ts +3751 -0
  285. package/app/web/pad.ts +174 -0
  286. package/app/web/reactions.ts +79 -0
  287. package/app/web/sheet.ts +36 -0
  288. package/app/web/synth.ts +140 -0
  289. package/app/web/tsconfig.json +5 -0
  290. package/app/web/ui.ts +263 -0
  291. package/app/web/weather.ts +306 -0
  292. package/app/web/words.ts +12 -0
  293. package/docs/characters.md +58 -0
  294. package/docs/npc-motion.md +69 -0
  295. package/docs/scenes.md +75 -0
  296. package/docs/setup.md +60 -0
  297. package/docs/sound.md +23 -0
  298. package/docs/studio.md +62 -0
  299. package/package.json +70 -4
@@ -0,0 +1,72 @@
1
+ // The look of a world: style_set changes it for the whole world, one place or the cast, and repaints what is made.
2
+ import fs from 'node:fs';
3
+ import { z } from 'zod';
4
+ import { readCastFixes } from '../scene/cast-fixes.ts';
5
+ import { readCorrections } from '../scene/corrections.ts';
6
+ import { agentBlocked } from '../studio/locks.ts';
7
+ import { WorldFiles } from '../world/files.ts';
8
+ import { Slug } from '../world/schema.ts';
9
+ import { castKeys, planLine, SCOPES, stylePlan } from '../world/style-plan.ts';
10
+ import { readStyled } from '../world/styled.ts';
11
+ import { ActionError, defineAction } from './registry.ts';
12
+
13
+ defineAction({
14
+ name: 'style_get',
15
+ reads: true,
16
+ description: 'The painting style of a world: world.json\'s, each place\'s and character\'s own where one was set, and the style each was last painted in.',
17
+ input: z.object({ slug: Slug }),
18
+ async run({ slug }, ctx) {
19
+ const files = new WorldFiles(slug, ctx.root);
20
+ if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
21
+ const world = files.world();
22
+ const fixes = readCastFixes(files);
23
+ return {
24
+ style: world.style,
25
+ kids: world.audience === 'kids' || fs.existsSync(files.path('drawings')),
26
+ places: Object.fromEntries(world.places.map((p) => [p.key, readCorrections(files.place(p.key)).style ?? null])),
27
+ cast: Object.fromEntries(castKeys(world, files).map((k) => [k, fixes[k]?.style ?? null])),
28
+ painted: readStyled(files),
29
+ };
30
+ },
31
+ });
32
+
33
+ defineAction({
34
+ name: 'style_set',
35
+ description: 'Change the painting style ("realistic oil painting, soft natural light, muted palette"): of the whole world (scope world, the default), '
36
+ + 'one place (scope place, key = place) or the cast (scope cast, key = one character or leave out for all). Every place is repainted from its '
37
+ + 'painting with the composition kept (one Muse edit), then cut out and measured again; characters are repainted from their figure, keeping '
38
+ + 'who they are, and their 3D models and sprite sheets made again. Children\'s drawings stay the reference. Without go it only answers what it '
39
+ + 'redoes and about what it costs: tell the person, then call again with go true once they agree. Runs as a job; history_undo takes it back.',
40
+ input: z.object({
41
+ slug: Slug,
42
+ style: z.string().min(10).describe('The new style in a few words: medium, palette, light, no artist names.'),
43
+ scope: z.enum(SCOPES).optional(),
44
+ key: Slug.optional().describe('The place (scope place) or the character (scope cast).'),
45
+ go: z.boolean().optional().describe('true runs it; leave out to get the estimate first.'),
46
+ }),
47
+ job: true,
48
+ async run({ slug, style, scope = 'world', key, go }, ctx) {
49
+ const files = new WorldFiles(slug, ctx.root);
50
+ if (!fs.existsSync(files.path('world.json'))) throw new ActionError(404, `no world ${slug} yet`);
51
+ const world = files.world();
52
+ if (scope === 'place' && !world.places.some((p) => p.key === key)) throw new ActionError(400, `scope place needs key, one of ${world.places.map((p) => p.key).join(', ')}`);
53
+ if (scope === 'cast' && key && !castKeys(world, files).includes(key)) throw new ActionError(404, `${slug} has no character ${key}: ${castKeys(world, files).join(', ')}`);
54
+ if (scope === 'world' && key) throw new ActionError(400, 'scope world takes no key');
55
+ const plan = stylePlan(files, world, scope, key);
56
+ const kids = world.audience === 'kids' || fs.existsSync(files.path('drawings'));
57
+ if (!go) {
58
+ return {
59
+ estimate: plan,
60
+ line: planLine(plan),
61
+ was: scope === 'place' ? readCorrections(files.place(key!)).style ?? world.style : world.style,
62
+ ...(kids && { note: 'made from children\'s drawings: they stay the reference, so the children still know their things' }),
63
+ next: 'tell the person what it redoes and costs; call again with go: true when they agree',
64
+ };
65
+ }
66
+ const blocked = agentBlocked(files.dir, ctx.by, scope === 'place' ? key : undefined);
67
+ if (blocked) throw new ActionError(423, blocked);
68
+ const args = ['style', slug, style, ...(scope === 'place' ? ['--place', key!] : scope === 'cast' ? ['--cast', key ?? 'all'] : [])];
69
+ const job = ctx.jobs.start(slug, args, `style ${scope}${key ? ` ${key}` : ''}`, ctx.by);
70
+ return { job, line: planLine(plan) };
71
+ },
72
+ });
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { defineAction } from './registry.ts';
6
+ import { VIDEO_CHUNK, VIDEO_MAX, VIDEO_STEP, VideoId, inspectVideo, readVideoPart, save, videoDir, videoState } from '../world/video.ts';
7
+
8
+ defineAction({ name: 'video_upload', description: 'Upload a video brief in ordered chunks, up to 2 GB and 4 hours. Start with name and size, then send id, byte offset and base64 data. Repeated identical chunks are safe.', input: z.object({ id: VideoId.optional(), name: z.string().max(200).optional(), size: z.number().int().positive().max(VIDEO_MAX).optional(), offset: z.number().int().nonnegative().optional(), data: z.string().max(Math.ceil(VIDEO_CHUNK / 3) * 4 + 4).optional() }), async run(input, ctx) {
9
+ if (!input.id) {
10
+ if (!input.name || !/\.(mp4|mov|webm|mkv|m4v|avi)$/i.test(input.name) || !input.size) throw new Error('Choose an MP4, MOV, WebM, MKV, M4V or AVI video, up to 2 GB.');
11
+ const id = randomBytes(16).toString('hex'), dir = videoDir(ctx.root, id);
12
+ fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'source'), '');
13
+ save(path.join(dir, 'upload.json'), { name: input.name, size: input.size, received: 0 }); return { id, received: 0 };
14
+ }
15
+ const dir = videoDir(ctx.root, input.id), state = videoState(dir), file = path.join(dir, 'source');
16
+ if (input.offset === undefined || !input.data) return { id: input.id, received: state.received };
17
+ const bytes = Buffer.from(input.data, 'base64');
18
+ if (!bytes.length || bytes.length > VIDEO_CHUNK || input.offset + bytes.length > state.size) throw new Error('Invalid video chunk size.');
19
+ if (input.offset < state.received) {
20
+ const fd = fs.openSync(file, 'r'); const old = Buffer.alloc(bytes.length);
21
+ try { fs.readSync(fd, old, 0, bytes.length, input.offset); } finally { fs.closeSync(fd); }
22
+ if (input.offset + bytes.length > state.received || !old.equals(bytes)) throw new Error('Conflicting video chunk.');
23
+ return { id: input.id, received: state.received };
24
+ }
25
+ if (input.offset !== state.received) throw new Error(`Resume the video at byte ${state.received}.`);
26
+ // Positional writes also recover safely if the process stops before saving the offset.
27
+ const fd = fs.openSync(file, 'r+'); try { fs.writeSync(fd, bytes, 0, bytes.length, input.offset); } finally { fs.closeSync(fd); }
28
+ state.received += bytes.length; save(path.join(dir, 'upload.json'), state);
29
+ return { id: input.id, received: state.received };
30
+ } });
31
+ defineAction({ name: 'video_inspect', description: 'Finish a video upload, inspect duration and audio, and report how many intervals need analysis.', input: z.object({ id: VideoId }), async run({ id }, ctx) {
32
+ const state = await inspectVideo(videoDir(ctx.root, id)); return { ...state, parts: Math.ceil(state.duration / VIDEO_STEP) };
33
+ } });
34
+ defineAction({ name: 'video_read', description: 'Read one 30-second interval of a movie, with frames and timestamped speech; intervals run in order. Results are cached for resuming.', input: z.object({ id: VideoId, index: z.number().int().nonnegative(), note: z.string().max(4000).default('') }), async run({ id, index, note }, ctx) { return readVideoPart(ctx.root, id, index, note); } });
35
+ defineAction({ name: 'video_result', description: 'Return a completed video interpretation and useful reference frames for review before creating a world. Keeps the source and full timestamped evidence.', input: z.object({ id: VideoId }), async run({ id }, ctx) {
36
+ const dir = videoDir(ctx.root, id), state = videoState(dir);
37
+ if (!state.duration) throw new Error('Inspect the video first.');
38
+ const count = Math.ceil(state.duration / VIDEO_STEP);
39
+ const parts = Array.from({ length: count }, (_, i) => JSON.parse(fs.readFileSync(path.join(dir, `part-${i}.json`), 'utf8')));
40
+ const selected = parts.flatMap((p) => p.reading.frames.filter((f: { use: boolean }) => f.use).map((f: { at: number }) => f.at)) as number[];
41
+ // Review capacity is explicit. All intervals remain archived even when only 32 reference images fit the world.
42
+ const times = selected.length <= 32 ? selected : Array.from({ length: 32 }, (_, i) => selected[Math.round(i * (selected.length - 1) / 31)]);
43
+ return { idea: parts.at(-1).reading.idea, observations: parts.map((p, i) => ({ at: i * VIDEO_STEP, text: p.reading.observations })), totalFrames: selected.length,
44
+ speech: parts.flatMap((p, i) => p.speech ? [{ at: i * VIDEO_STEP, ...p.speech }] : []),
45
+ photos: times.map((at) => ({ name: `video-${id.slice(0, 6)}-${at}s.jpg`, data: `data:image/jpeg;base64,${fs.readFileSync(path.join(dir, `frame-${at}.jpg`)).toString('base64')}` })) };
46
+ } });
@@ -0,0 +1,109 @@
1
+ // Looking at a place the way the fix tools see it: what the hero can and cannot reach and why, or what
2
+ // SAM cut out for each thing. The agent gets the picture as an image, so it never has to dig in files.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { z } from 'zod';
7
+ import { maskFromPng } from '../image/mask.ts';
8
+ import { PALETTE, svgLabel, tintOver } from '../image/overlay.ts';
9
+ import { maskBox } from '../image/raster.ts';
10
+ import { Geometry } from '../play/geometry.ts';
11
+ import { diagnose, diagnosisPicture } from '../scene/diagnose.ts';
12
+ import { readCorrections } from '../scene/corrections.ts';
13
+ import { Depth } from '../scene/layers.ts';
14
+ import { gridImage } from '../scene/locate.ts';
15
+ import { Slug } from '../world/schema.ts';
16
+ import { ActionError, defineAction } from './registry.ts';
17
+
18
+ type Box = [number, number, number, number];
19
+
20
+ const iou = (a: Box, b: Box) => {
21
+ const w = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0]));
22
+ const h = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
23
+ const area = (r: Box) => (r[2] - r[0]) * (r[3] - r[1]);
24
+ return (w * h) / (area(a) + area(b) - w * h || 1);
25
+ };
26
+
27
+ /** Each thing's mask against the box Spark saw it in: a mask far from its box has cut out the wrong thing. */
28
+ async function thingMasks(dir: string) {
29
+ const read = <T>(f: string): T | null => (fs.existsSync(path.join(dir, f)) ? (JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as T) : null);
30
+ const made = read<{ things: Record<string, { kind?: string; phrase?: string; source?: string; coarse?: boolean }> }>('masks.json')?.things ?? {};
31
+ const seen = read<{ things: Record<string, { box: Box | null }> }>('locate.json')?.things ?? {};
32
+ // A box someone already fixed stands for where the thing is, not Spark's first guess.
33
+ const fixed = readCorrections(dir).things;
34
+ return Promise.all(Object.entries(made).map(async ([key, t]) => {
35
+ const file = path.join(dir, 'masks', `${key}.png`);
36
+ const mask = fs.existsSync(file) ? await maskFromPng(fs.readFileSync(file)) : null;
37
+ const box = (mask && maskBox(mask)) as Box | undefined;
38
+ const spark = (fixed[key]?.box as Box | undefined) ?? seen[key]?.box ?? null;
39
+ const overlap = box && spark ? Math.round(iou(box, spark) * 100) / 100 : null;
40
+ return { key, ...t, maskBox: box ?? null, expectedBox: spark, overlap, ...(overlap !== null && overlap < 0.3 && { suspect: 'the mask is not where the thing is: SAM may have cut out its neighbour; mask_fix box (the real box) or phrase' }), mask };
41
+ }));
42
+ }
43
+
44
+ defineAction({
45
+ name: 'place_view',
46
+ reads: true,
47
+ description: 'Look at a place, as a picture you see plus the same in words; use it instead of reading files. '
48
+ + 'layer "walk" (default): the painting with the 60 px cell grid floor_cells takes, reached floor green, unreached red, each gap to bridge '
49
+ + 'as a yellow dashed line, unreachable stand spots red, the start green; in words per floor how much is reached, each unreached patch with '
50
+ + 'the cells of its gap and the things in it, spots off the floor with the nearest reachable point. '
51
+ + 'layer "masks": each thing\'s mask tinted and labelled, its mask box solid and the box it should be in dashed (the fixed box, else the one Spark saw); in words per thing its kind, '
52
+ + 'SAM phrase, how the mask was found and how well it overlaps that box (low overlap: SAM cut out the wrong thing, fix with mask_fix box or phrase). '
53
+ + 'layer "water": where the water moves (tinted magenta), with the grid water_area takes; in words which way it flows and the cells the author added or removed. '
54
+ + 'layer "painting": the painting alone with the grid. "blockin": the authoritative layout plan; "first": the first painted candidate, including one rejected before masks.',
55
+ input: z.object({ slug: Slug, place: z.string().min(1), layer: z.enum(['walk', 'masks', 'water', 'painting', 'blockin', 'first']).optional() }),
56
+ async run({ slug, place, layer = 'walk' }, ctx) {
57
+ const dir = path.join(ctx.root, slug, 'places', place);
58
+ const at = (f: string) => path.join(dir, f);
59
+ const painting = ['painting.png', 'blockin.png'].map(at).find((f) => fs.existsSync(f));
60
+ if (!painting) throw new ActionError(404, `${slug}/${place} has no painting yet: build it first`);
61
+ const shown = async (picture: Buffer, file: string, words: Record<string, unknown>) => {
62
+ // Kept beside the other review pictures, so the page can show what the agent looked at.
63
+ fs.writeFileSync(at(file), picture);
64
+ const jpeg = await sharp(picture).jpeg({ quality: 78 }).toBuffer();
65
+ return { ...words, picture: `places/${place}/${file}`, images: [{ mimeType: 'image/jpeg', data: jpeg.toString('base64') }] };
66
+ };
67
+ if (layer === 'blockin' || layer === 'first') {
68
+ const file = layer === 'blockin' ? 'blockin.png' : 'painting-first.png';
69
+ if (!fs.existsSync(at(file))) throw new ActionError(404, `${slug}/${place} has no ${file} yet`);
70
+ return shown(await gridImage(fs.readFileSync(at(file))), `view-${layer}.png`, { layer });
71
+ }
72
+ if (layer === 'painting') return shown(await gridImage(fs.readFileSync(painting)), 'view-painting.png', { layer });
73
+ if (layer === 'water') {
74
+ const depth = Depth.parse(fs.existsSync(at('depth.json')) ? JSON.parse(fs.readFileSync(at('depth.json'), 'utf8')) : {});
75
+ const water = fs.existsSync(at('layers.json')) ? (JSON.parse(fs.readFileSync(at('layers.json'), 'utf8')) as { water?: { mask: string; box: Box; flow?: [number, number] } }).water : undefined;
76
+ const words = { layer, flow: depth.flow, flowHint: depth.flowHint, added: depth.waterAdd, removed: depth.waterRemove };
77
+ const grid = await gridImage(fs.readFileSync(painting));
78
+ if (!water || !fs.existsSync(at(water.mask))) return shown(grid, 'view-water.png', { ...words, moving: 'none: no water moves here (SAM found none, or the backdrop step has not run); water_area add marks it' });
79
+ // The mask is white with the water as alpha, over the water's box.
80
+ const alpha = await sharp(fs.readFileSync(at(water.mask))).ensureAlpha().extractChannel(3).toBuffer();
81
+ const [x0, y0, x1, y1] = water.box;
82
+ const tint = await sharp({ create: { width: x1 - x0, height: y1 - y0, channels: 3, background: { r: 255, g: 0, b: 200 } } }).joinChannel(await sharp(alpha).linear(0.55, 0).toBuffer()).png().toBuffer();
83
+ const picture = await sharp(grid).composite([{ input: tint, left: x0, top: y0 }]).png().toBuffer();
84
+ const [ux, uy] = water.flow ?? [1, 0];
85
+ const way = !ux && !uy ? 'still (sways in place)' : `flowing ${Math.abs(ux) >= Math.abs(uy) ? (ux > 0 ? 'left to right' : 'right to left') : uy > 0 ? 'toward the viewer' : 'away into the picture'}`;
86
+ return shown(picture, 'view-water.png', { ...words, moving: `${way}, in the box ${water.box.join(',')}` });
87
+ }
88
+ if (layer === 'masks') {
89
+ if (!fs.existsSync(at('masks.json'))) throw new ActionError(404, `${slug}/${place} has no masks yet: build it up to masks first`);
90
+ const things = await thingMasks(dir);
91
+ const tints = things.filter((t) => t.mask).map((t, i) => ({ mask: t.mask!, rgb: PALETTE[i % PALETTE.length], alpha: 0.45 }));
92
+ const svg = things.flatMap((t, i) => {
93
+ const rgb = PALETTE[i % PALETTE.length];
94
+ const out: string[] = [];
95
+ if (t.maskBox) out.push(`<rect x="${t.maskBox[0]}" y="${t.maskBox[1]}" width="${t.maskBox[2] - t.maskBox[0]}" height="${t.maskBox[3] - t.maskBox[1]}" fill="none" stroke="rgb(${rgb.join(',')})" stroke-width="3"/>`);
96
+ if (t.expectedBox) out.push(`<rect x="${t.expectedBox[0]}" y="${t.expectedBox[1]}" width="${t.expectedBox[2] - t.expectedBox[0]}" height="${t.expectedBox[3] - t.expectedBox[1]}" fill="none" stroke="rgb(${rgb.join(',')})" stroke-width="2" stroke-dasharray="8 6"/>`);
97
+ const anchor = t.maskBox ?? t.expectedBox;
98
+ if (anchor) out.push(svgLabel(anchor[0] + 4, anchor[1] + 18, `${t.key}${t.suspect ? ' ?' : ''}`, rgb));
99
+ return out;
100
+ }).join('');
101
+ const picture = await tintOver(await gridImage(fs.readFileSync(painting)), tints, svg);
102
+ return shown(picture, 'view-masks.png', { layer, things: things.map(({ mask: _, ...t }) => t) });
103
+ }
104
+ if (!fs.existsSync(at('geometry.json'))) throw new ActionError(404, `${slug}/${place} has no geometry.json yet: build it up to derive first`);
105
+ const g = Geometry.parse(JSON.parse(fs.readFileSync(at('geometry.json'), 'utf8')));
106
+ const diagnosis = diagnose(g);
107
+ return shown(await diagnosisPicture(fs.readFileSync(painting), g, diagnosis), 'diagnose.png', { layer, diagnosis });
108
+ },
109
+ });
@@ -0,0 +1,38 @@
1
+ // Weather: what falls on a place and what drips there (the weather step), as the author wants it.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { WeatherWish } from '../play/weather.ts';
6
+ import { agentBlocked } from '../studio/locks.ts';
7
+ import { Slug } from '../world/schema.ts';
8
+ import { ActionError, defineAction } from './registry.ts';
9
+
10
+ defineAction({
11
+ name: 'weather_set',
12
+ description: 'Set the weather of a place when the person says it should (not) rain or snow there, it falls too hard or too lightly, '
13
+ + 'or water should drip from something: fall auto (as Spark reads the picture and the mood), none, rain or snow; strength 0..1 '
14
+ + '(0.3 drizzle, 1 downpour); fog 0..1 when mist should (not) drift there (0 clear, 0.3 haze, 0.7 thick fog); a hint in their words ("water drips from the tavern sign"). Kept in places/<place>/weather-wish.json '
15
+ + 'across rebuilds; reruns the weather step of that place as a job.',
16
+ input: z.object({
17
+ slug: Slug,
18
+ place: z.string().min(1),
19
+ fall: z.enum(['auto', 'none', 'rain', 'snow']),
20
+ strength: z.number().min(0).max(1).optional(),
21
+ fog: z.number().min(0).max(1).optional().describe('Mist drifting over the place: 0 none, 0.3 haze, 0.7 thick fog.'),
22
+ hint: z.string().min(3).optional().describe('What should drip or fall, in words, for Spark to follow.'),
23
+ }),
24
+ job: true,
25
+ async run({ slug, place, fall, strength, fog, hint }, ctx) {
26
+ const dir = path.join(ctx.root, slug);
27
+ const blocked = agentBlocked(dir, ctx.by);
28
+ if (blocked) throw new ActionError(423, blocked);
29
+ const placeDir = path.join(dir, 'places', place);
30
+ if (!fs.existsSync(path.join(placeDir, 'geometry.json'))) throw new ActionError(404, `${slug}/${place} has no geometry yet: build it first`);
31
+ const file = path.join(placeDir, 'weather-wish.json');
32
+ return ctx.jobs.start(slug, ['weather', slug, place], `weather ${place}: ${fall}`, ctx.by, () => {
33
+ const was = fs.existsSync(file) ? WeatherWish.parse(JSON.parse(fs.readFileSync(file, 'utf8'))) : WeatherWish.parse({});
34
+ const wish: WeatherWish = { ...was, fall, ...(strength !== undefined && { strength }), ...(fog !== undefined && { fog }), ...(hint !== undefined && { hint }) };
35
+ fs.writeFileSync(file, `${JSON.stringify(wish, null, 2)}\n`);
36
+ });
37
+ },
38
+ });
@@ -0,0 +1,289 @@
1
+ // Reading a world: what exists, what each place has got so far, what verify said.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { withPlay } from '../scene/playbook.ts';
6
+ import { currentVerdict } from '../scene/status.ts';
7
+ import type { Verdict } from '../scene/verify.ts';
8
+ import { readCorrections } from '../scene/corrections.ts';
9
+ import { placeTrouble } from '../studio/trouble.ts';
10
+ import { withThingEdits, WorldFiles } from '../world/files.ts';
11
+ import { liveVoiceFailures } from '../sound/data.ts';
12
+ import { Slug, World } from '../world/schema.ts';
13
+ import { storyNeeds } from '../story/check.ts';
14
+ import { ActionError, defineAction } from './registry.ts';
15
+
16
+ const STAGES = [
17
+ ['layout', 'layout.json'],
18
+ ['blockin', 'blockin.png'],
19
+ ['painting', 'painting.png'],
20
+ ['masks', 'masks.json'],
21
+ ['geometry', 'geometry.json'],
22
+ ['backdrop', 'backdrop.png'],
23
+ ['verify', 'verify.json'],
24
+ ] as const;
25
+
26
+ /** What a place looks like after each step: Spark's plan (named; the bare block-in in worlds laid out before plans), Muse's first paint and the final one, SAM's masks, the depth map, the walk grid. */
27
+ const LAYERS = [
28
+ ['blockin', 'plan.png', 'blockin.png'],
29
+ ['first', 'painting-first.png'],
30
+ ['painting', 'painting.png'],
31
+ ['masks', 'masks/overlay.png'],
32
+ ['depth', 'depth.png'],
33
+ ['walk', 'walk.png'],
34
+ ] as const;
35
+
36
+ function stamp(file: string): number | null {
37
+ try {
38
+ return Math.round(fs.statSync(file).mtimeMs);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function readJson<T>(file: string): T | null {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(file, 'utf8')) as T;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /** Calls the models answered (sent, not memo hits) per model, from memo/calls.jsonl. */
53
+ export function callCounts(dir: string, byVia = false): Record<string, number> {
54
+ const counts: Record<string, number> = {};
55
+ const log = path.join(dir, 'memo', 'calls.jsonl');
56
+ if (!fs.existsSync(log)) return counts;
57
+ for (const line of fs.readFileSync(log, 'utf8').split('\n')) {
58
+ if (!line) continue;
59
+ 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;
63
+ if (!call.hit) counts[key] = (counts[key] ?? 0) + 1;
64
+ } catch {}
65
+ }
66
+ return counts;
67
+ }
68
+
69
+ /** How long each build step usually takes here: the median over the worlds built so far, for a world that has no times of its own. */
70
+ // A first world on a new machine: the step times of a four-place build (2026-10-27), so its first job still says about how long.
71
+ const FIRST_BUILD: Record<string, number> = { world: 17, story: 47, layout: 30, paint: 66, masks: 66, plates: 45, icons: 21, derive: 161, backdrop: 48, weather: 21, reactions: 21, cast: 61, model: 179, sprites: 188, sound: 67, voices: 49, verify: 1 };
72
+
73
+ export function typicalSeconds(root: string): Record<string, number> {
74
+ const seen: Record<string, number[]> = {};
75
+ for (const slug of fs.existsSync(root) ? fs.readdirSync(root) : []) {
76
+ const steps = readJson<{ steps?: Record<string, { seconds?: number }> }>(path.join(root, slug, 'build.json'))?.steps ?? {};
77
+ for (const [step, s] of Object.entries(steps)) if (typeof s?.seconds === 'number') (seen[step] ??= []).push(s.seconds);
78
+ }
79
+ return { ...FIRST_BUILD, ...Object.fromEntries(Object.entries(seen).map(([step, all]) => [step, all.sort((a, b) => a - b)[Math.floor(all.length / 2)]])) };
80
+ }
81
+
82
+ export function worldSummary(root: string, slug: string) {
83
+ const dir = path.join(root, slug);
84
+ const parsed = World.safeParse(readJson(path.join(dir, 'world.json')));
85
+ const build = readJson<{ steps?: Record<string, { at: string; seconds: number }> }>(path.join(dir, 'build.json'));
86
+ const places = parsed.success ? withThingEdits(parsed.data, dir).places : [];
87
+ const verdicts = places.map((p) => currentVerdict(dir, p.key, p));
88
+ const cover = places.map((p) => ['painting.png', 'blockin.png'].map((f) => `places/${p.key}/${f}`).find((f) => fs.existsSync(path.join(dir, f)))).find(Boolean);
89
+ return {
90
+ slug,
91
+ title: parsed.success ? parsed.data.title : slug,
92
+ pitch: parsed.success ? parsed.data.pitch : null,
93
+ issue: parsed.success ? null : fs.existsSync(path.join(dir, 'world.json')) ? 'world.json does not parse' : 'no world.json yet',
94
+ places: places.length,
95
+ playable: verdicts.filter((v, i) => v?.ok && !placeTrouble(dir, places[i].key)).length,
96
+ verified: verdicts.filter(Boolean).length,
97
+ steps: Object.keys(build?.steps ?? {}),
98
+ cover: cover ? { file: cover, v: stamp(path.join(dir, cover)) } : null,
99
+ changed: stamp(dir),
100
+ // Children's drawings or recordings kept with it: a workshop world, which opens on them until it is built.
101
+ workshop: fs.existsSync(path.join(dir, 'drawings')) || fs.existsSync(path.join(dir, 'told')),
102
+ };
103
+ }
104
+
105
+ defineAction({
106
+ name: 'world_list',
107
+ reads: true,
108
+ description: 'List the worlds in worlds/: title, places, how many are playable, which build steps ran.',
109
+ input: z.object({}),
110
+ async run(_, ctx) {
111
+ if (!fs.existsSync(ctx.root)) return [];
112
+ return fs.readdirSync(ctx.root, { withFileTypes: true })
113
+ .filter((d) => d.isDirectory() && Slug.safeParse(d.name).success)
114
+ .map((d) => worldSummary(ctx.root, d.name))
115
+ .sort((a, b) => (b.changed ?? 0) - (a.changed ?? 0));
116
+ },
117
+ });
118
+
119
+ defineAction({
120
+ name: 'world_status',
121
+ reads: true,
122
+ description: 'One world in detail: per place which stages exist (layout, painting, masks, geometry, backdrop, verify), verify errors and flags, build steps, model calls sent.',
123
+ input: z.object({ slug: Slug }),
124
+ async run({ slug }, ctx) {
125
+ const dir = path.join(ctx.root, slug);
126
+ // A world being built for the first time has no folder for its first seconds, but it has its job.
127
+ if (!fs.existsSync(dir) && !ctx.jobs.list(slug).length) throw new ActionError(404, `no world ${slug} yet`);
128
+ const raw = World.safeParse(readJson(path.join(dir, 'world.json')));
129
+ const parsed = raw.success ? { success: true as const, data: withThingEdits(raw.data, dir) } : raw;
130
+ const build = readJson<{ steps?: Record<string, unknown> }>(path.join(dir, 'build.json'));
131
+ const animation = readJson<Record<string, { face?: string | null; audio?: string[] }>>(path.join(dir, 'face-preparation.json'));
132
+ const buildSteps: Record<string, unknown> = { ...build?.steps };
133
+ // A step whose work is on disk but not in build.json (the world and story written by the idea flow, an agent's
134
+ // own step, an older build) was still made: the bar shows it kept from before and opens it, never "not part of this build".
135
+ const typical = typicalSeconds(ctx.root);
136
+ const placeKeys = parsed.success ? parsed.data.places.map((p) => p.key) : [];
137
+ const anyPlace = (f: string) => placeKeys.some((k) => fs.existsSync(path.join(dir, 'places', k, f)));
138
+ const made: Record<string, () => boolean> = {
139
+ world: () => fs.existsSync(path.join(dir, 'world.json')), story: () => fs.existsSync(path.join(dir, 'story.json')),
140
+ layout: () => anyPlace('layout.json'), paint: () => anyPlace('painting.png'), masks: () => anyPlace('masks.json'),
141
+ icons: () => fs.existsSync(path.join(dir, 'items', 'items.json')), derive: () => anyPlace('geometry.json'), backdrop: () => anyPlace('backdrop.png'),
142
+ weather: () => anyPlace('weather.json'), reactions: () => anyPlace('reactions.json'),
143
+ sound: () => fs.existsSync(path.join(dir, 'sound', 'sounds.json')), voices: () => fs.existsSync(path.join(dir, 'voices.json')), verify: () => anyPlace('verify.json'),
144
+ };
145
+ for (const [step, has] of Object.entries(made)) if (!buildSteps[step] && has()) buildSteps[step] = { seconds: typical[step] ?? 30, kept: true };
146
+ if (animation) {
147
+ // A face whose retry failed still speaks with the face it had (face.json): not a missing face.
148
+ const speaks = (key: string) => /^[a-z0-9][\w-]*$/.test(key) && fs.existsSync(path.join(dir, 'cast', key, 'face.json'));
149
+ const warnings = Object.entries(animation).flatMap(([key, state]) => [speaks(key) ? null : state.face, ...(state.audio ?? [])].filter(Boolean).map(reason => `${key}: ${reason}`));
150
+ if (buildSteps.faces || warnings.length) buildSteps.faces = { ...(buildSteps.faces as object ?? {}), status: warnings.length ? 'incomplete' : 'complete', warnings };
151
+ }
152
+ const places = (parsed.success ? parsed.data.places : []).map((p) => {
153
+ const at = (f: string) => path.join(dir, 'places', p.key, f);
154
+ const stages = Object.fromEntries(STAGES.map(([name, f]) => [name, stamp(at(f))]));
155
+ const verdict = currentVerdict(dir, p.key, p);
156
+ const image = ['painting.png', 'blockin.png'].find((f) => stages[f === 'painting.png' ? 'painting' : 'blockin']);
157
+ return {
158
+ key: p.key,
159
+ shows: p.shows,
160
+ things: p.things.map((t) => ({ key: t.key, role: t.role })),
161
+ exits: p.exits,
162
+ stages,
163
+ image: image ? { file: `places/${p.key}/${image}`, v: stamp(at(image)) } : null,
164
+ // The pictures each step leaves, in build order: Studio stacks them as the place is built.
165
+ layers: Object.fromEntries(LAYERS.map(([name, ...fs]) => [name, fs.find((f) => stamp(at(f)))]).filter(([, f]) => f).map(([name, f]) => [name, { file: `places/${p.key}/${f}`, v: stamp(at(f!)) }])),
166
+ verdict: verdict && { ok: verdict.ok, errors: verdict.errors.map((e) => withPlay(e, verdict.flags)), flags: verdict.flags.map((f) => withPlay(f, verdict.flags)) },
167
+ /** Why the place is not playable in one line, and the step place_fix rebuilds it from (null: the agent's). */
168
+ trouble: placeTrouble(dir, p.key),
169
+ };
170
+ });
171
+ return {
172
+ ...worldSummary(ctx.root, slug),
173
+ world: parsed.success ? { start: parsed.data.start, hero: parsed.data.hero, style: parsed.data.style } : null,
174
+ placeDetail: places,
175
+ build: buildSteps,
176
+ typical,
177
+ calls: callCounts(dir),
178
+ callsVia: callCounts(dir, true),
179
+ jobs: ctx.jobs.list(slug).slice(-10),
180
+ audioMisses: audioMisses(ctx.root, slug, dir),
181
+ };
182
+ },
183
+ });
184
+
185
+ /** Sounds and spoken lines the last run could not make: the game plays without them, Studio offers a retry. */
186
+ function audioMisses(root: string, slug: string, dir: string) {
187
+ const sounds = readJson<{ failures?: Record<string, string> }>(path.join(dir, 'sound', 'sounds.json'))?.failures ?? {};
188
+ // Only lines the story still says: a rewritten line's old failure is history.
189
+ const lines = fs.existsSync(path.join(dir, 'world.json')) ? liveVoiceFailures(new WorldFiles(slug, root)) : [];
190
+ return { sounds: Object.entries(sounds).map(([id, reason]) => ({ id, reason })), lines };
191
+ }
192
+
193
+ defineAction({
194
+ name: 'place_status',
195
+ reads: true,
196
+ description: 'One place up close: each thing\'s kind, SAM phrase and how its mask was found (coarse ones need a look), each floor, '
197
+ + 'the review pictures (masks/overlay.png, walk.png), the stand spots, the fixes made so far, and each error that keeps it from being playable '
198
+ + 'with its playbook: where to look, the fix tools to try in order, and the costly last resort.',
199
+ input: z.object({ slug: Slug, place: z.string().min(1) }),
200
+ async run({ slug, place }, ctx) {
201
+ const dir = path.join(ctx.root, slug, 'places', place);
202
+ if (!new WorldFiles(slug, ctx.root).world().places.some((p) => p.key === place)) throw new ActionError(404, `${slug} has no place ${place}`);
203
+ type Made = { kind: string; phrase: string; source: string; coarse: boolean; brushed?: boolean };
204
+ const masks = readJson<{ things: Record<string, Made>; floors: Record<string, Omit<Made, 'kind' | 'phrase'>>; flagged: string[] }>(path.join(dir, 'masks.json'));
205
+ const geometry = readJson<{ things: Array<{ key: string; role?: string; box: [number, number, number, number]; stand: [number, number] }>; start: [number, number] }>(path.join(dir, 'geometry.json'));
206
+ const picture = (f: string) => (fs.existsSync(path.join(dir, f)) ? { file: `places/${place}/${f}`, v: stamp(path.join(dir, f)) } : null);
207
+ // A step that broke for this place in the last build stands before verify's errors: verify did not see it.
208
+ const report = readJson<{ places?: Array<{ place: string; ok: boolean; issues?: Verdict['errors'] }> }>(path.join(ctx.root, slug, 'build.json'))?.places?.find((r) => r.place === place);
209
+ const verdict = currentVerdict(path.join(ctx.root, slug), place);
210
+ // Whether thing_remove takes a thing: a prop the story does not use (exits, items and people are the story's).
211
+ const raw = World.safeParse(readJson(path.join(ctx.root, slug, 'world.json')));
212
+ const roles = new Map((raw.success ? withThingEdits(raw.data, path.join(ctx.root, slug)).places.find((p) => p.key === place)?.things ?? [] : []).map((t) => [t.key, t.role]));
213
+ const storyText = fs.existsSync(path.join(ctx.root, slug, 'story.json')) ? fs.readFileSync(path.join(ctx.root, slug, 'story.json'), 'utf8') : '';
214
+ const added = readCorrections(dir).added;
215
+ const removable = (key: string) => {
216
+ const role = roles.get(key);
217
+ if (added[key]) return { role: 'prop', removable: true };
218
+ if (role !== 'prop') return { role, removable: false, why: `${role === 'npc' ? 'a person' : `an ${role ?? 'unknown thing'}`} in the story: ask the agent to change the story` };
219
+ if (storyText && storyNeeds(JSON.parse(storyText), place, key)) return { role, removable: false, why: 'the story uses it: ask the agent to change the story' };
220
+ return { role, removable: true };
221
+ };
222
+ const broke = report?.issues?.filter((i) => i.code === 'STEP_FAILED') ?? [];
223
+ const composition = readJson<{ ok: boolean; pending?: boolean; error?: string; attempts?: Array<{ issues: string[] }> }>(path.join(dir, 'painting-layout.json'));
224
+ return {
225
+ playable: broke.length === 0 && (verdict?.ok ?? null),
226
+ errors: [...broke, ...(verdict?.errors ?? [])].map((e) => withPlay(e, verdict?.flags)),
227
+ flags: verdict?.flags ?? [],
228
+ place,
229
+ paintingLayout: composition && { ok: composition.ok, pending: composition.pending ?? false, attempts: composition.attempts?.length ?? 0, issues: composition.attempts?.at(-1)?.issues ?? [], error: composition.error ?? null },
230
+ pictures: { painting: picture('painting.png') ?? picture('blockin.png'), masks: picture('masks/overlay.png'), walk: picture('walk.png') },
231
+ things: Object.entries(masks?.things ?? {}).map(([key, t]) => ({ key, ...t, stand: geometry?.things.find((g) => g.key === key)?.stand ?? null, ...removable(key) })),
232
+ floors: Object.entries(masks?.floors ?? {}).map(([key, f]) => ({ key, ...f })),
233
+ flagged: masks?.flagged ?? [],
234
+ start: geometry?.start ?? null,
235
+ npcs: (geometry?.things ?? []).filter((g) => g.role === 'npc').map((g) => ({ key: g.key, box: g.box, at: [Math.round((g.box[0] + g.box[2]) / 2), Math.round(g.box[3])] as [number, number] })),
236
+ corrections: readCorrections(dir),
237
+ };
238
+ },
239
+ });
240
+
241
+ defineAction({
242
+ name: 'agent_sessions',
243
+ reads: true,
244
+ description: 'The recorded conversations with the agent on a world (studio/sessions), newest first: when, which agent, the prompts, how long. Open one in the Studio with ?replay=<file>.',
245
+ input: z.object({ slug: Slug }),
246
+ async run({ slug }, ctx) {
247
+ const dir = path.join(ctx.root, slug, 'studio', 'sessions');
248
+ if (!fs.existsSync(dir)) return [];
249
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((file) => {
250
+ const lines = fs.readFileSync(path.join(dir, file), 'utf8').split('\n').filter(Boolean).flatMap((l) => {
251
+ try {
252
+ return [JSON.parse(l) as { at: string; type: string; text?: string; agent?: string; turn?: { id: number } }];
253
+ } catch {
254
+ return [];
255
+ }
256
+ });
257
+ const states = lines.filter((l) => l.type === 'state');
258
+ return {
259
+ file,
260
+ started: lines[0]?.at ?? null,
261
+ ended: lines.at(-1)?.at ?? null,
262
+ agents: [...new Set(states.map((l) => l.agent).filter(Boolean))],
263
+ prompts: lines.filter((l) => l.type === 'user').map((l) => String(l.text).slice(0, 120)),
264
+ turns: new Set(states.map((l) => l.turn?.id)).size,
265
+ replayable: states.length > 0,
266
+ };
267
+ }).sort((a, b) => String(b.started).localeCompare(String(a.started)));
268
+ },
269
+ });
270
+
271
+ defineAction({
272
+ name: 'world_delete',
273
+ description: 'Delete a world: the person only, from the Studio\'s menu or the command line, never the agent. It is moved to worlds/.trash/<slug>-<time>, '
274
+ + 'so it can be brought back by moving the folder back. Refused while a job runs in it.',
275
+ input: z.object({ slug: Slug }),
276
+ async run({ slug }, ctx) {
277
+ if (ctx.by === 'agent') throw new ActionError(403, 'only the person deletes a world');
278
+ const dir = path.join(ctx.root, slug);
279
+ if (!fs.existsSync(dir)) throw new ActionError(404, `no world ${slug}`);
280
+ const busy = ctx.jobs.busy(slug);
281
+ if (busy) throw new ActionError(409, `${busy.label} is running in ${slug}: stop it first`);
282
+ const trash = path.join(ctx.root, '.trash');
283
+ fs.mkdirSync(trash, { recursive: true });
284
+ const to = path.join(trash, `${slug}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
285
+ fs.renameSync(dir, to);
286
+ ctx.events.publish('world.deleted', { world: slug, by: ctx.by });
287
+ return { deleted: slug, movedTo: path.relative(ctx.root, to), restore: `move ${path.relative(process.cwd(), to)} back to ${path.relative(process.cwd(), dir)}` };
288
+ },
289
+ });
@@ -0,0 +1,39 @@
1
+ // diorama action <name> ['<json>']: any Studio action from a terminal, with the same input the GUI and the
2
+ // agent send. A job action runs in the foreground here and prints its output.
3
+ import path from 'node:path';
4
+ import '../actions/index.ts';
5
+ import { actions, describeActions, runAction } from '../actions/registry.ts';
6
+ import { defineCommand } from '../registry.ts';
7
+ import { Events } from '../studio/events.ts';
8
+ import { snapshotBeforeJob } from '../studio/history.ts';
9
+ import { Jobs, type Job } from '../studio/jobs.ts';
10
+
11
+ defineCommand({
12
+ name: 'action',
13
+ usage: 'action <name> [\'<json>\'] run a Studio action (the same ones the GUI and Muse Code use); no name lists them',
14
+ async run([name, json]) {
15
+ if (!name) {
16
+ for (const a of describeActions()) console.log(` ${a.name.padEnd(14)} ${a.description}\n ${''.padEnd(14)} input ${JSON.stringify(a.input.properties ?? {})}`);
17
+ return;
18
+ }
19
+ const events = new Events();
20
+ const root = path.resolve('worlds');
21
+ const ctx = { by: 'cli' as const, root, events, jobs: new Jobs(events, { echo: true, beforeStart: snapshotBeforeJob(root, events) }) };
22
+ let input: unknown = {};
23
+ try {
24
+ input = json ? JSON.parse(json) : {};
25
+ } catch {
26
+ throw new Error(`the input must be JSON, e.g. diorama action ${name} '{"slug":"foghorn"}'`);
27
+ }
28
+ const result = await runAction(name, input, ctx);
29
+ const started = result && typeof result === 'object' ? ('job' in result ? (result as { job: Job }).job : actions.get(name)?.job ? result as Job : undefined) : undefined;
30
+ if (started && typeof started.id === 'string') {
31
+ const job = await ctx.jobs.wait(started.id);
32
+ if (job.state !== 'done') throw new Error(`${job.label}: ${job.state}${job.error ? `, ${job.error}` : ''}`);
33
+ return;
34
+ }
35
+ // Pictures are for the agent; here their file is enough.
36
+ const { images: _, ...shown } = (result && typeof result === 'object' && !Array.isArray(result) ? result : { result }) as { images?: unknown };
37
+ console.log(JSON.stringify(Array.isArray(result) || !result || typeof result !== 'object' ? result : shown, null, 2));
38
+ },
39
+ });