@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,503 @@
1
+ // diorama build <slug> ["<idea>"]: runs the steps, each as soon as what it needs is made (src/world/schedule.ts):
2
+ // every place on its own, the cast, voices and sound alongside. Keeps a build.json of what happened.
3
+ import { facesState } from '../scene/faces-settings.ts';
4
+ import { inStep } from '../meta/step.ts';
5
+ import fs from 'node:fs';
6
+ import { defineCommand, emit, registry, takeOption } from '../registry.ts';
7
+ import { Geometry } from '../play/geometry.ts';
8
+ import { maskFromPng, type Mask } from '../image/mask.ts';
9
+ import { autoFixes } from '../scene/autofix.ts';
10
+ import { Corrections, readCorrections, writeCorrections } from '../scene/corrections.ts';
11
+ import { Layout } from '../scene/layout.ts';
12
+ import { withPlay, type Play } from '../scene/playbook.ts';
13
+ import { gridOf, type Finding, type Verdict } from '../scene/verify.ts';
14
+ import { WorldFiles } from '../world/files.ts';
15
+ import { drawingFiles } from '../world/drawings.ts';
16
+ import { toldFiles } from '../world/told.ts';
17
+ import { NEEDS, needs, PLACE_STEPS, STEPS, type Step } from '../world/schedule.ts';
18
+ import { itemSources } from '../scene/icons.ts';
19
+ import { Story } from '../story/schema.ts';
20
+ import { assetReport } from '../scene/assets.ts';
21
+ import { FacePreparationError } from '../scene/face-work.ts';
22
+
23
+ /** How the build's own repaints of a missing thing start, so a new take can drop them. */
24
+ const BUILD_REPAINT = 'Paint the';
25
+
26
+ export { PLACE_STEPS, STEPS } from '../world/schedule.ts';
27
+
28
+ /** What each place step leaves that later steps read; a place missing one never got past that step. */
29
+ const MADE: Partial<Record<Step, string>> = { layout: 'blockin.png', paint: 'painting.png', masks: 'masks.json', derive: 'geometry.json' };
30
+
31
+ /**
32
+ * The earliest step before `step` whose file the place lacks, with that file; null when it has them all. Steps this
33
+ * build ran itself (`ran`) are not looked at: they made the file or set the place aside already.
34
+ */
35
+ export function placeGap(placeDir: string, step: Step, ran: readonly Step[] = []): { step: Step; file: string } | null {
36
+ for (const s of PLACE_STEPS.slice(0, PLACE_STEPS.indexOf(step)).filter((x) => !ran.includes(x))) {
37
+ const file = MADE[s];
38
+ if (file && !fs.existsSync(`${placeDir}/${file}`)) return { step: s, file };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export const gapError = (place: string, gap: { step: string; file: string }) =>
44
+ `${place} never got past ${gap.step} (no ${gap.file}): fix why ${gap.step} failed there (place_status, the job log) and rebuild the place from ${gap.step}`;
45
+
46
+ type Report = { place: string; ok: boolean; errors: string[]; flagged: string[]; issues: Array<Finding & { play?: Play }>; from?: Step };
47
+ type Repair = { place: string; by: 'auto'; fixes: string[]; ok: boolean };
48
+ const line = (f: Finding) => `${f.code}${f.key ? ` ${f.key}` : ''}: ${f.detail}`;
49
+
50
+ /**
51
+ * Model calls `step` sent (not answered from the memo) since `since`, and what they cost, read from the memo's call
52
+ * log. Only calls tagged with this step count: steps run side by side, and counting every call since the step
53
+ * began gave each step the others' calls too (the 3D step showed 137 Spark calls where it made about 25).
54
+ */
55
+ export function stepCalls(files: WorldFiles, step: string, since: string): { sent: Record<string, number>; usd: number } {
56
+ const log = files.path('memo', 'calls.jsonl');
57
+ const sent: Record<string, number> = { spark: 0, muse: 0, sam: 0, sound: 0, music: 0, voice: 0, space: 0 };
58
+ let usd = 0;
59
+ if (!fs.existsSync(log)) return { sent, usd };
60
+ for (const line of fs.readFileSync(log, 'utf8').split('\n')) {
61
+ if (!line) continue;
62
+ const call = JSON.parse(line) as { at: string; model: string; hit: boolean; step?: string; usd?: number };
63
+ if (call.at < since || call.hit || call.step !== step) continue;
64
+ sent[call.model] = (sent[call.model] ?? 0) + 1;
65
+ usd += call.usd ?? 0;
66
+ }
67
+ return { sent, usd: Math.round(usd * 1000) / 1000 };
68
+ }
69
+
70
+ defineCommand({
71
+ name: 'build',
72
+ usage: 'build <slug> ["<idea>"] run every step, world to verify, in order [--from STEP] [--to STEP] [--places N] [--photo room.jpg] [--hero-photo me.jpg] [--drawings DIR] [--told DIR] [--kids] [--language L] [--place KEY: only the place steps, for one place] [--repair off: leave errors for the agent and the person, no free fixes] [--max-take N: new paintings the build may ask for, default 2, 0 keeps them]',
73
+ async run(args) {
74
+ const from = takeOption(args, 'from', STEPS[0]) as Step;
75
+ const to = takeOption(args, 'to', STEPS[STEPS.length - 1]) as Step;
76
+ const places = takeOption(args, 'places', '4');
77
+ const photo = takeOption(args, 'photo', '');
78
+ const heroPhoto = takeOption(args, 'hero-photo', '');
79
+ // Children's drawings and recordings of their story, and the game's language, go to the world step as they are.
80
+ const told = takeOption(args, 'told', '');
81
+ const worldArgs = ['drawings', 'told', 'language'].flatMap((o) => {
82
+ const value = o === 'told' ? told : takeOption(args, o, '');
83
+ return value ? [`--${o}`, value] : [];
84
+ });
85
+ const kids = args.indexOf('--kids');
86
+ if (kids >= 0) (args.splice(kids, 1), worldArgs.push('--kids'));
87
+ const only = takeOption(args, 'place', '');
88
+ const repair = takeOption(args, 'repair', 'auto');
89
+ // The highest take of a painting the build may ask for by itself (FLOOR_LOST); 0 keeps every painting as it is.
90
+ const maxTake = Number(takeOption(args, 'max-take', '2'));
91
+ const [slug, idea] = args;
92
+ if (!slug) throw new Error('usage: diorama build <slug> ["<idea>"] [--from STEP] [--to STEP]');
93
+ const range = STEPS.slice(STEPS.indexOf(from), STEPS.indexOf(to) + 1).filter((s) => !only || PLACE_STEPS.includes(s));
94
+ if (range.length === 0) throw new Error(`steps run ${STEPS.join(' > ')}; --from ${from} --to ${to} selects none`);
95
+ const files = new WorldFiles(slug);
96
+ if (range[0] === 'world' && !idea && !told && toldFiles(files).length === 0 && drawingFiles(files).length === 0) throw new Error('the world step needs an idea: diorama build <slug> "<idea>" (or --told DIR, recordings of the story)');
97
+
98
+ if (only && !files.world().places.some((p) => p.key === only)) throw new Error(`${slug} has no place ${only}`);
99
+ const logFile = files.path('build.json');
100
+ const log = (fs.existsSync(logFile) ? files.readJson(logFile) : { steps: {} }) as { steps: Record<string, unknown>; places?: Report[]; repairs?: Array<Repair & { at: string }> };
101
+ // A place whose step threw is set aside with why; the other places go on, and so do the world's own steps.
102
+ const broken = new Map<string, { step: Step; error: string }>();
103
+ const placeStep = async (step: Step, place: string) => {
104
+ // A place step that lost the network (every retry of a request failed: "fetch failed") gets the step
105
+ // again after a pause, twice, before the place is set aside: an outage of a few minutes costs time, not a place.
106
+ for (let again = 0; ; again++) {
107
+ try {
108
+ await inStep(step, () => registry.get(step)!.run([slug, place]));
109
+ return;
110
+ } catch (reason) {
111
+ const error = reason instanceof Error ? reason.message : String(reason);
112
+ if (again < 2 && /fetch failed|ECONNRESET|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(error)) {
113
+ console.log(`${place}: ${step} lost the network (${error.slice(0, 80)}); trying the step again in ${60 * (again + 1)} s`);
114
+ await new Promise((r) => setTimeout(r, 60_000 * (again + 1)));
115
+ continue;
116
+ }
117
+ throw reason;
118
+ }
119
+ }
120
+ };
121
+ /** Runs a place step for one place; a place that throws is set aside, the others go on. */
122
+ const runPlace = async (step: Step, place: string) => {
123
+ // A place an earlier job left unfinished (its paint refused, say) is set aside at the step it never got past,
124
+ // not failed here with a missing file, so the report and job_retry point at the step to redo.
125
+ const gap = placeGap(files.place(place), step, range);
126
+ if (gap) {
127
+ broken.set(place, { step: gap.step, error: gapError(place, gap) });
128
+ console.log(`${place}: ${step} skipped: ${gapError(place, gap)}`);
129
+ emit('place.fail', { place, step: gap.step, error: gapError(place, gap) });
130
+ return;
131
+ }
132
+ try {
133
+ await placeStep(step, place);
134
+ } catch (reason) {
135
+ const error = reason instanceof Error ? reason.message : String(reason);
136
+ broken.set(place, { step, error });
137
+ console.log(`${place}: ${step} failed, set aside: ${error}`);
138
+ emit('place.fail', { place, step, error });
139
+ }
140
+ };
141
+ /** Per place: whether it is playable, everything flagged and let through, and for each error what to try (the playbook). */
142
+ const reportOf = (place: string): Report | null => {
143
+ const gone = broken.get(place);
144
+ if (gone) {
145
+ const issue = withPlay({ code: 'STEP_FAILED', key: gone.step, detail: gone.error });
146
+ return { place, ok: false, errors: [line(issue)], flagged: [], issues: [issue], from: gone.step };
147
+ }
148
+ const file = files.place(place, 'verify.json');
149
+ if (!range.includes('verify') || !fs.existsSync(file)) return null;
150
+ const v = files.readJson(file) as Verdict;
151
+ return { place, ok: v.ok, errors: v.errors.map(line), flagged: v.flags.map(line), issues: v.errors.map((e) => withPlay(e, v.flags)) };
152
+ };
153
+
154
+ emit('build.start', { slug, steps: range });
155
+ const saved = process.exitCode;
156
+ const optionalWarnings = new Map<Step,string[]>();
157
+ const run = (step: Step) => range.includes(step);
158
+ /** Each step's run: when it began, its longest single run (one place, or the world), what is still to do. */
159
+ const runs = new Map<Step, { at: string; started: number; longest: number; left: number; done: Promise<void>; end: () => void; fail: (error: unknown) => void }>();
160
+ for (const step of range) {
161
+ let end!: () => void;
162
+ let fail!: (error: unknown) => void;
163
+ const done = new Promise<void>((resolve, reject) => ((end = resolve), (fail = reject)));
164
+ // Whoever waits on it hears of a failure; nobody waiting is fine.
165
+ done.catch(() => {});
166
+ runs.set(step, { at: '', started: 0, longest: 0, left: 1, done, end, fail });
167
+ }
168
+ /** A world step failed: nothing waiting on an unfinished step starts, so the build ends with that failure. */
169
+ const abort = (error: unknown) => {
170
+ for (const r of runs.values()) r.fail(error);
171
+ };
172
+ /** How many runs make the step: one for the world's, one per place (known once world.json is). */
173
+ const open = (step: Step, units: number) => {
174
+ const r = runs.get(step)!;
175
+ r.left = units;
176
+ if (units === 0) r.end();
177
+ };
178
+ const begin = (step: Step) => {
179
+ const r = runs.get(step)!;
180
+ if (r.started) return Date.now();
181
+ r.started = Date.now();
182
+ r.at = new Date().toISOString();
183
+ console.log(`\n# ${step}`);
184
+ emit('step.start', { step });
185
+ return r.started;
186
+ };
187
+ /** One unit of a step finished (a place, or the world's own run); the last one ends the step. */
188
+ const finish = (step: Step, since?: number) => {
189
+ const r = runs.get(step)!;
190
+ if (since) r.longest = Math.max(r.longest, Date.now() - since);
191
+ if (--r.left > 0) return;
192
+ r.end();
193
+ if (!r.started) return;
194
+ // A step's time is what one place (or the world) took with it, so it adds up along the web, not the clock.
195
+ const done = { at: r.at, seconds: Math.round(r.longest / 100) / 10, ...stepCalls(files, step, r.at), ...(optionalWarnings.has(step) && { status: 'incomplete', warnings: optionalWarnings.get(step) }) };
196
+ // A one-place rerun does not stand for the whole step.
197
+ if (!only) {
198
+ log.steps[step] = done;
199
+ files.write(logFile, log);
200
+ }
201
+ emit('step.end', { step, ...done, ...(only && { place: only }) });
202
+ };
203
+ /** Resolves when `step` is done everywhere (every place has finished it or was set aside); at once when not run. */
204
+ const ready = (step: Step) => (run(step) ? runs.get(step)!.done : Promise.resolve());
205
+ const worldStep = async (step: Step, args: string[]) => {
206
+ await Promise.all(needs(step).map(ready));
207
+ const since = begin(step);
208
+ if (step === 'faces' && !facesState().enabled) {
209
+ console.log('Talking mouths are off (Setup): characters speak with voice and gestures.');
210
+ finish(step, since); return;
211
+ }
212
+ try {
213
+ await inStep(step, () => registry.get(step)!.run(args));
214
+ } catch (error) {
215
+ // Speaking faces never fail a build: whatever went wrong, the game plays with gestures.
216
+ if (step === 'faces') {
217
+ optionalWarnings.set(step,error instanceof FacePreparationError ? error.failures : [error instanceof Error ? error.message : String(error)]);
218
+ console.log(`Speaking animation incomplete; game build continues: ${optionalWarnings.get(step)!.join('; ')}`);
219
+ finish(step,since);return;
220
+ }
221
+ emit('step.fail', { step, error: error instanceof Error ? error.message : String(error) });
222
+ abort(error);
223
+ throw error;
224
+ }
225
+ finish(step, since);
226
+ };
227
+
228
+ const worldSteps = STEPS.filter((s) => run(s) && !PLACE_STEPS.includes(s) && s !== 'icons');
229
+ const tasks: Array<Promise<void>> = [];
230
+ const go = (step: Step, args: string[]) => {
231
+ const task = worldStep(step, args);
232
+ // Every world step is awaited at the end; this only keeps a failure from being reported twice as unhandled.
233
+ task.catch(() => {});
234
+ tasks.push(task);
235
+ };
236
+ if (run('world')) go('world', [slug, ...(idea ? [idea] : []), '--places', places, ...worldArgs, ...(photo ? ['--photo', photo] : []), ...(heroPhoto ? ['--hero-photo', heroPhoto] : [])]);
237
+ // Speaking faces in a build: the portrait (what a conversation shows) and the 2D rig only. Talk sheets and 3D
238
+ // surfaces cost a Muse atlas and many vision checks each, about 20 minutes a character, for lips nobody watches.
239
+ for (const s of worldSteps.filter((s) => s !== 'world')) go(s, s === 'faces' ? [slug, '--image-only'] : [slug]);
240
+
241
+ // The places start once world.json says which there are; each goes through its steps on its own.
242
+ const reports: Report[] = [];
243
+ const repairs: Repair[] = [];
244
+ let keys: string[] = [];
245
+ const placesDone = ready('world').then(async () => {
246
+ keys = files.world().places.map((p) => p.key).filter((p) => !only || p === only);
247
+ for (const s of PLACE_STEPS.filter(run)) open(s, keys.length);
248
+ // Icons, per place once its things are cut out; items picked up nowhere once the story is there.
249
+ const story = () => (fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null);
250
+ if (run('icons')) open('icons', keys.length + 1);
251
+ let iconsQueue = Promise.resolve();
252
+ const iconTasks: Array<Promise<void>> = [];
253
+ /** items/items.json is read and written by each run, so icons runs one after another. */
254
+ const icons = (items: () => string[] | null) => {
255
+ const next = iconsQueue.then(async () => {
256
+ await ready('story');
257
+ const keys = items();
258
+ // null: no story to say which items, so icons is run as a whole (it says there is nothing to do).
259
+ if (keys && keys.length === 0) return finish('icons');
260
+ const since = begin('icons');
261
+ try {
262
+ await inStep('icons', () => registry.get('icons')!.run([slug, ...(keys ?? [])]));
263
+ } catch (error) {
264
+ emit('step.fail', { step: 'icons', error: error instanceof Error ? error.message : String(error) });
265
+ abort(error);
266
+ throw error;
267
+ }
268
+ finish('icons', since);
269
+ });
270
+ iconsQueue = next.catch(() => {});
271
+ iconTasks.push(next);
272
+ };
273
+ const itemsFrom = (place: string | null) => () => {
274
+ const s = story();
275
+ if (!s) return place === null ? null : [];
276
+ const placed = new Set(keys);
277
+ return Object.entries(itemSources(s)).filter(([, from]) => (place === null ? !from || !placed.has(from.place) : from?.place === place)).map(([item]) => item);
278
+ };
279
+ if (run('icons')) icons(itemsFrom(null));
280
+
281
+ const chains = await Promise.allSettled(keys.map(async (place) => {
282
+ for (const step of PLACE_STEPS.filter(run)) {
283
+ await Promise.all(NEEDS[step].map(ready));
284
+ if (!broken.has(place)) {
285
+ const since = begin(step);
286
+ emit('place.step', { place, step });
287
+ await runPlace(step, place);
288
+ // Said per place, so Studio can tell a place between steps (waiting on another one) from one being worked on.
289
+ emit('place.step', { place, step, done: true });
290
+ finish(step, since);
291
+ } else finish(step);
292
+ if (step === 'masks' && run('icons')) icons(itemsFrom(place));
293
+ }
294
+ const report = await repairPlace(place);
295
+ if (report) {
296
+ reports.push(report);
297
+ // Said as soon as the place is done, so it can be played while the others are still being made.
298
+ emit('place.verdict', report);
299
+ }
300
+ }));
301
+ if (run('icons') && !run('masks')) for (const p of keys) icons(itemsFrom(p));
302
+ for (const settled of [...chains, ...(await Promise.allSettled(iconTasks))]) if (settled.status === 'rejected') throw settled.reason;
303
+ });
304
+ placesDone.catch(abort);
305
+ tasks.push(placesDone);
306
+ // Everything runs to the end before a failure is told, so no step is cut off half written.
307
+ const settled = await Promise.allSettled(tasks);
308
+ process.exitCode = saved;
309
+ const failure = settled.find((s): s is PromiseRejectedResult => s.status === 'rejected');
310
+ if (failure) throw failure.reason;
311
+ if (keys.length && keys.every((p) => broken.has(p))) {
312
+ const last = [...broken.values()].sort((a, b) => STEPS.indexOf(b.step) - STEPS.indexOf(a.step))[0].step;
313
+ const error = `${last} failed for every place: ${[...broken].map(([p, b]) => `${p}: ${b.error}`).join('; ')}`;
314
+ emit('step.fail', { step: last, error });
315
+ throw new Error(error);
316
+ }
317
+ if (reports.length) {
318
+ const report = keys.map((p) => reports.find((r) => r.place === p)).filter((r): r is Report => !!r);
319
+ if (repairs.length) log.repairs = [...(log.repairs ?? []), ...repairs.map((x) => ({ at: new Date().toISOString(), ...x }))].slice(-50);
320
+ log.places = [...(log.places ?? []).filter((r) => !report.some((n) => n.place === r.place)), ...report];
321
+ files.write(logFile, log);
322
+ console.log('\n# report');
323
+ for (const r of report) {
324
+ console.log(`${r.place}: ${r.ok ? 'playable' : 'NOT playable'}${r.flagged.length ? `, flagged and continued: ${r.flagged.length}` : ''}`);
325
+ for (const i of r.issues) {
326
+ console.log(` error ${line(i)}`);
327
+ if (i.play) console.log(` try: ${i.play.tools.join(' | ')}`);
328
+ }
329
+ for (const f of r.flagged) console.log(` flag ${f}`);
330
+ }
331
+ if (report.some((r) => !r.ok)) process.exitCode = 1;
332
+ }
333
+ // The cast and the sound, which verify does not look at: a character with no walk, a sound that stayed silent.
334
+ if (['cast', 'model', 'sprites', 'sound', 'voices'].some((s) => run(s as Step))) {
335
+ try {
336
+ const assets = assetReport(files);
337
+ if (assets.length) console.log(`\n# made\n${assets.map((l) => ` ${l}`).join('\n')}`);
338
+ } catch { /* a report only */ }
339
+ }
340
+
341
+ /**
342
+ * The place's verdict once its steps are done, after the free tier of repair: fixes the build can make itself
343
+ * with no model call (src/scene/autofix.ts), then the place is rebuilt from the step they feed. Whatever is
344
+ * left goes to the agent, then the person. Null when verify did not run and the place did not break.
345
+ */
346
+ async function repairPlace(place: string): Promise<Report | null> {
347
+ let r = reportOf(place);
348
+ if (!r || repair === 'off') return r;
349
+ // Two passes: a bridge to one patch can bring the next patch within reach of a second bridge.
350
+ for (let pass = 0; pass < 2 && !r.ok && !r.from; pass++) {
351
+ const dir = files.place(place);
352
+ let fixes: ReturnType<typeof autoFixes>;
353
+ try {
354
+ const geometry = Geometry.parse(files.readJson(files.place(place, 'geometry.json')));
355
+ const floors = Layout.parse(files.readJson(files.place(place, 'layout.json'))).floors.map((f) => f.key);
356
+ // Walk-on masks: a bridge is drawn along stairs only when they reach both floors.
357
+ const masks: Record<string, Mask> = {};
358
+ for (const t of geometry.things) {
359
+ if (t.kind === 'walk-on' && t.mask && fs.existsSync(files.place(place, t.mask))) masks[t.key] = await maskFromPng(fs.readFileSync(files.place(place, t.mask)));
360
+ }
361
+ fixes = autoFixes(geometry, floors, r.issues, masks);
362
+ } catch (error) {
363
+ fixes = [];
364
+ console.log(`${place}: could not look for a free fix: ${error instanceof Error ? error.message : error}`);
365
+ }
366
+ const before = readCorrections(dir);
367
+ const after = Corrections.parse(JSON.parse(JSON.stringify(before)));
368
+ for (const f of fixes) f.apply(after);
369
+ if (fixes.length === 0 || JSON.stringify(after) === JSON.stringify(before)) {
370
+ console.log(`\n# repair ${place}: no free fix for ${[...new Set(r.issues.map((i) => i.code))].join(', ')}; left for the agent`);
371
+ break;
372
+ }
373
+ writeCorrections(dir, after);
374
+ const start = fixes.some((f) => f.from === 'masks') ? 'masks' : 'derive';
375
+ console.log(`\n# repair ${place}: ${fixes.map((f) => f.what).join('; ')}`);
376
+ emit('repair.start', { place, by: 'auto', fixes: fixes.map((f) => f.what) });
377
+ for (const step of PLACE_STEPS.slice(PLACE_STEPS.indexOf(start))) {
378
+ if (!broken.has(place)) await runPlace(step, place);
379
+ }
380
+ r = reportOf(place)!;
381
+ repairs.push({ place, by: 'auto', fixes: fixes.map((f) => f.what), ok: r.ok });
382
+ emit('repair.end', { place, by: 'auto', ok: r.ok });
383
+ }
384
+ return recutCards(place, await paintMissing(place, await newTake(place, r)));
385
+ }
386
+
387
+ // Do not paint a bridge to a snapped interaction spot and brush it open without measuring it.
388
+ // Paint now restores the plan's composition before masks; remaining gaps need observed floor evidence.
389
+
390
+ /**
391
+ * A card whose mask leaves out a painted part of its thing (CARD_CUT: the boat's bow, a lamp on its post) leaves
392
+ * that part smeared on the backdrop when the card slides. The flag's box says where; the thing's box is widened
393
+ * to take it in (as mask_fix box does), so masks asks SAM by the wider box and joins what it finds there that
394
+ * touches the thing; the card carries it and the backdrop is made again without it. Once per cut. A round dab
395
+ * brushed over the part instead took the sky round it too: a disc on the card, and a hover outline looping round it.
396
+ */
397
+ async function recutCards(place: string, r: Report | null): Promise<Report | null> {
398
+ if (!r || r.from || repair === 'off') return r;
399
+ const file = files.place(place, 'verify.json');
400
+ if (!fs.existsSync(file)) return r;
401
+ const cuts = (files.readJson(file) as Verdict).flags.filter((f) => f.code === 'CARD_CUT' && f.key && f.box);
402
+ if (!cuts.length) return r;
403
+ const dir = files.place(place);
404
+ const before = readCorrections(dir);
405
+ const after = Corrections.parse(JSON.parse(JSON.stringify(before)));
406
+ const geometry = fs.existsSync(files.place(place, 'geometry.json')) ? Geometry.parse(files.readJson(files.place(place, 'geometry.json'))) : undefined;
407
+ const what: string[] = [];
408
+ for (const f of cuts) {
409
+ const was = after.things[f.key!]?.box ?? geometry?.things.find((t) => t.key === f.key)?.box;
410
+ if (!was) continue;
411
+ const [x0, y0, x1, y1] = f.box!;
412
+ const box: [number, number, number, number] = [Math.min(was[0], x0), Math.min(was[1], y0), Math.max(was[2], x1), Math.max(was[3], y1)].map(Math.round) as [number, number, number, number];
413
+ if (box.every((v, i) => v === Math.round(was[i]))) continue;
414
+ (after.things[f.key!] ??= { strokes: [] }).box = box;
415
+ what.push(`widened ${f.key}'s box to [${box.join(', ')}] to take in the part its card cut through at [${f.box!.join(', ')}]`);
416
+ }
417
+ if (!what.length) return r;
418
+ writeCorrections(dir, after);
419
+ console.log(`\n# repair ${place}: ${what.join('; ')}`);
420
+ emit('repair.start', { place, by: 'auto', fixes: what });
421
+ for (const step of PLACE_STEPS.slice(PLACE_STEPS.indexOf('masks'))) {
422
+ if (!broken.has(place)) await runPlace(step, place);
423
+ }
424
+ const next = reportOf(place) ?? r;
425
+ repairs.push({ place, by: 'auto', fixes: what, ok: next.ok });
426
+ emit('repair.end', { place, by: 'auto', ok: next.ok });
427
+ return next;
428
+ }
429
+
430
+ async function newTake(place: string, r: Report | null): Promise<Report | null> {
431
+ if (!r || r.ok || r.from || !r.issues.some((i) => i.code === 'FLOOR_LOST')) return r;
432
+ const dir = files.place(place);
433
+ const before = readCorrections(dir);
434
+ const take = (before.take ?? 0) + 1;
435
+ if (take > maxTake) return r;
436
+ const after = Corrections.parse(JSON.parse(JSON.stringify(before)));
437
+ after.take = take;
438
+ after.repaint = after.repaint.filter((x) => !x.fix.startsWith(BUILD_REPAINT));
439
+ writeCorrections(dir, after);
440
+ const lost = r.issues.filter((i) => i.code === 'FLOOR_LOST').map((i) => i.key).join(', ');
441
+ const what = [`painted take ${take}: floor ${lost} was painted away from its layout (one Muse painting)`];
442
+ console.log(`\n# repair ${place}: ${what[0]}`);
443
+ emit('repair.start', { place, by: 'auto', fixes: what });
444
+ for (const step of PLACE_STEPS.slice(PLACE_STEPS.indexOf('paint'))) {
445
+ if (!broken.has(place)) await runPlace(step, place);
446
+ }
447
+ const next = reportOf(place) ?? r;
448
+ repairs.push({ place, by: 'auto', fixes: what, ok: next.ok });
449
+ emit('repair.end', { place, by: 'auto', ok: next.ok });
450
+ return next;
451
+ }
452
+
453
+ /**
454
+ * A thing the story needs that Spark finds nowhere in the painting: no mask fix can find what was never painted,
455
+ * so it gets one Muse edit at its layout box (the one repaint_region per place the agent may make unasked), and
456
+ * the place is rebuilt from paint. Once per thing: a repaint already asked for it is not asked again.
457
+ */
458
+ async function paintMissing(place: string, r: Report | null): Promise<Report | null> {
459
+ if (!r || r.ok || r.from) return r;
460
+ const keys = [...new Set(r.issues.filter((i) => i.code === 'THING_NOT_PAINTED' && i.key).map((i) => i.key!))];
461
+ if (!keys.length) return r;
462
+ const dir = files.place(place);
463
+ const before = readCorrections(dir);
464
+ const after = Corrections.parse(JSON.parse(JSON.stringify(before)));
465
+ const asked: string[] = [];
466
+ try {
467
+ const layout = Layout.parse(files.readJson(files.place(place, 'layout.json')));
468
+ const listed = files.world().places.find((p) => p.key === place)?.things ?? [];
469
+ for (const key of keys) {
470
+ const t = layout.things.find((x) => x.key === key);
471
+ if (!t) continue;
472
+ const looks = (listed.find((x) => x.key === key)?.looks ?? key.replace(/-/g, ' ')).replace(/^(a|an|the)\s+/i, '');
473
+ const fix = `${BUILD_REPAINT} ${looks} here, clearly visible and whole, standing where it meets the floor, in the painting's own style and light; keep everything around it`;
474
+ if (after.repaint.some((x) => x.fix === fix)) continue;
475
+ // The layout box with room round it, so Muse can fit the thing in, at least 120 px each way.
476
+ const w = Math.max(120, t.width * 1.4), h = Math.max(120, t.height * 1.3);
477
+ const region: [number, number, number, number] = [
478
+ Math.max(0, Math.round(t.x - w / 2)), Math.max(0, Math.round(t.footY - h + 12)),
479
+ Math.min(1200, Math.round(t.x + w / 2)), Math.min(896, Math.round(t.footY + 24)),
480
+ ];
481
+ after.repaint.push({ region, fix });
482
+ asked.push(key);
483
+ }
484
+ } catch (error) {
485
+ console.log(`${place}: could not ask for a repaint: ${error instanceof Error ? error.message : error}`);
486
+ return r;
487
+ }
488
+ if (!asked.length) return r;
489
+ writeCorrections(dir, after);
490
+ const what = asked.map((k) => `repainted ${k} at its layout box (one Muse edit)`);
491
+ console.log(`\n# repair ${place}: ${what.join('; ')}`);
492
+ emit('repair.start', { place, by: 'auto', fixes: what });
493
+ for (const step of PLACE_STEPS.slice(PLACE_STEPS.indexOf('paint'))) {
494
+ if (!broken.has(place)) await runPlace(step, place);
495
+ }
496
+ const next = reportOf(place) ?? r;
497
+ repairs.push({ place, by: 'auto', fixes: what, ok: next.ok });
498
+ emit('repair.end', { place, by: 'auto', ok: next.ok });
499
+ return next;
500
+ }
501
+ console.log(`\nbuild ${slug}: ${range.join(' > ')} done · ${files.path('build.json')}`);
502
+ },
503
+ });
@@ -0,0 +1,142 @@
1
+ import { worldStyleReference } from '../world/style-reference.ts';
2
+ // diorama cast <slug>: the hero and every NPC of world.json as cut-out figures the runtime walks around.
3
+ import fs from 'node:fs';
4
+ import { bindExistingAnimation, bindAnimation } from '../scene/animation-source.ts';
5
+ import { museEdit } from '../meta/muse.ts';
6
+ import { askSparkJson } from '../meta/spark.ts';
7
+ import { z } from 'zod';
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';
11
+ import { photoStanding } from '../scene/photo.ts';
12
+ import { rigFigure } from '../scene/rig.ts';
13
+ import { checkSheet } from '../scene/rig-check.ts';
14
+ import { PART_NAMES } from '../play/rig.ts';
15
+ import { fixedLooks, readCastFixes } from '../scene/cast-fixes.ts';
16
+ import { drawingFor, players, readDrawn } from '../world/drawings.ts';
17
+ import { WorldFiles } from '../world/files.ts';
18
+ import { approvedConcept } from '../world/sketch.ts';
19
+ import { castStyle, markStyled, readStyled } from '../world/styled.ts';
20
+
21
+ defineCommand({
22
+ name: 'cast',
23
+ usage: 'cast <slug> [key...] paint the hero, NPCs and players (heroes for more gamepads, drawings marked player) with Muse, cut them out with SAM 3.1 (cast/<key>.png) [--take N] [--photo me.jpg: paint the hero from a photo, standing even if they sit in it] (a character a child drew, drawings.json, is painted from the drawing)',
24
+ async run(args) {
25
+ const take = Number(takeOption(args, 'take', '0'));
26
+ const photoPath = takeOption(args, 'photo', '');
27
+ const [slug, ...only] = args;
28
+ if (!slug) throw new Error('usage: diorama cast <slug> [key...] [--take N]');
29
+ const files = new WorldFiles(slug);
30
+ const world = files.world();
31
+ const styled = readStyled(files);
32
+ // world --hero-photo keeps the photo next to the cast, so a plain `cast` uses it too.
33
+ const kept = files.path('cast', `${world.hero.key}-photo.png`);
34
+ const photo = photoPath ? fs.readFileSync(photoPath) : fs.existsSync(kept) ? fs.readFileSync(kept) : undefined;
35
+ // Only a person standing whole in the photo keeps the photo's pose; anyone sitting, lying or cut off is painted standing.
36
+ const standing = photo ? await photoStanding(files.memo, photo) : false;
37
+ if (photo && !standing) console.log(`${world.hero.key}: the photo does not show them standing whole; painted standing`);
38
+ // A character a child drew is painted from what was read off the drawing, as its preview was, so the preview is the figure.
39
+ const drawn = readDrawn(files);
40
+ const drawnLooks = (w: Who): Who => ({ ...w, looks: drawn.find((d) => d.key === w.key && (d.kind === 'hero' || d.kind === 'npc' || d.kind === 'player'))?.looks ?? w.looks });
41
+ const npcs = world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc'));
42
+ const fixes = readCastFixes(files);
43
+ const cast: Who[] = [world.hero, ...new Map(npcs.map((n) => [n.key, n])).values(), ...players(files)].filter((w) => !only.length || only.includes(w.key)).map((w) => fixedLooks(files, drawnLooks(w)));
44
+ const missing: string[] = [];
45
+ // cast/figures.json: how tall each one stands and whether upright, so a cat or a child is drawn at its own
46
+ // size and only those walking like a person get a rig (and later a model and sprites).
47
+ const bodies = { ...readBodies(files, fs.existsSync), ...(await castBodies(files.memo, world, cast)) };
48
+ files.write(files.path('cast', 'figures.json'), bodies);
49
+ const odd = Object.entries(bodies).filter(([, b]) => Math.abs(b.height - 1.62) > 0.25 || !b.upright);
50
+ if (odd.length) console.log(`bodies: ${odd.map(([k, b]) => `${k} ${b.height} m${b.upright ? '' : ', not upright'}`).join(', ')}`);
51
+ await Promise.all(cast.map(async (who) => {
52
+ // The person's description and chosen take (cast/fixes.json) win over the world's.
53
+ // An NPC that cannot be cut out is set aside (the game draws a stand-in) so the rest of the cast is made.
54
+ // A character painted before in another style (style_set) is repainted from that painting, so they stay who
55
+ // they were; the hero from a photo is painted from the photo again, in the new style.
56
+ const style = castStyle(files, world, who.key);
57
+ const was = styled.cast[who.key];
58
+ const before = files.path('cast', `${who.key}-painting.png`);
59
+ const heroPhoto = who.key === world.hero.key ? photo : undefined;
60
+ const restyle = was && was !== style && !heroPhoto && fs.existsSync(before) ? { painting: fs.readFileSync(before), was } : undefined;
61
+ const made = await paintFigure(files.memo, { ...world, style }, who, fixes[who.key]?.take ?? take, { photo: heroPhoto, standing, drawing: drawingFor(files, who.key), concept: approvedConcept(files, 'character', who.key), styleReference: worldStyleReference(files, style), restyle }, bodies[who.key]?.upright !== false).catch((error: Error) => {
62
+ if (who.key === world.hero.key) throw error;
63
+ missing.push(`${who.key}: ${error.message}`);
64
+ return null;
65
+ });
66
+ if (!made) return;
67
+ const { painting, figure } = made;
68
+ bindExistingAnimation(files, who.key);
69
+ files.write(files.path('cast', `${who.key}-painting.png`), painting);
70
+ files.write(files.path('cast', `${who.key}.png`), figure);
71
+ markStyled(files, 'cast', who.key, style);
72
+ // The same painting cut into parts, so the runtime can walk the figure.
73
+ 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
+ }));
76
+ for (const m of missing) console.log(`set aside, shown as a stand-in: ${m}`);
77
+ console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
78
+ },
79
+ });
80
+
81
+ /**
82
+ * The painting made walkable: the whole figure warped by its bones into a walk sheet (cast/<key>/walk.png) and
83
+ * rig.json with the joints and how the walk measured. A walk that tears is kept but marked, and the game slides
84
+ * the painted figure as one piece instead. Parts of an older cut-out rig are removed.
85
+ */
86
+ export async function rigCast(files: WorldFiles, key: string, painting: Buffer) {
87
+ try {
88
+ let { rig, parts, issues } = await rigFigure(files.memo, key, painting);
89
+ rig.check = rig.sheet ? await checkSheet(rig, parts[rig.sheet.file]) : undefined;
90
+ if (rig.check && !rig.check.ok) {
91
+ // Try a separate neutral source for the warp; never overwrite the creator's painting.
92
+ try {
93
+ const neutral = await museEdit(files.memo, { label: `rig-neutral:${key}`, images: [painting], size: '1024x1024',
94
+ prompt: 'The same character, identity, face, clothes, colours, proportions and every object they hold. Show the whole character standing still in a relaxed neutral walk-ready pose: both feet on the ground, legs slightly apart and visible, no stepping, no overlapping legs. Keep any carried object firmly in the same hand, complete and not touching the ground. No other objects. Plain flat background, no shadow, no text.' });
95
+ const schema = z.object({ matches: z.boolean(), neutral: z.boolean(), heldPreserved: z.boolean(), reason: z.string() });
96
+ const verdict = schema.parse(await askSparkJson(files.memo, { label: `rig-neutral-check:${key}`, schema, images: [painting,neutral], personal: true,
97
+ 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
+ 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
+ candidate.rig.check = candidate.rig.sheet ? await checkSheet(candidate.rig, candidate.parts[candidate.rig.sheet.file]) : undefined;
101
+ if (!candidate.rig.check?.ok) throw new Error(candidate.rig.check?.why ?? 'neutral walk did not pass');
102
+ rig = candidate.rig; parts = candidate.parts; issues = [...issues, 'walk prepared from a verified neutral pose'];
103
+ } catch (error) {
104
+ issues.push(`neutral walk could not be prepared: ${error instanceof Error ? error.message : String(error)}`);
105
+ }
106
+ }
107
+ for (const name of PART_NAMES) fs.rmSync(files.path('cast', key, `${name}.png`), { force: true });
108
+ for (const [file, png] of Object.entries(parts)) files.write(files.path('cast', key, file), png);
109
+ files.write(files.path('cast', key, 'rig.json'), rig);
110
+ bindAnimation(files, key, 'rig');
111
+ files.write(rigAttemptFile(files, key), { ok: true, reason: issues.length ? issues.join('; ') : null, at: new Date().toISOString() });
112
+ const verdict = rig.check && !rig.check.ok ? ` · walk torn (${rig.check.why}): slides as one piece` : '';
113
+ console.log(`${key}: ${files.path('cast', `${key}.png`)} · walk sheet ${rig.sheet?.frames ?? 0} frames${verdict}${issues.map((i) => ` · ${i}`).join('')}`);
114
+ } catch (error) {
115
+ const reason = error instanceof Error ? error.message : String(error);
116
+ // Kept for cast_status (rigIssue): the figure still shows, sliding as one piece, and the person sees why.
117
+ files.write(rigAttemptFile(files, key), { ok: false, reason, at: new Date().toISOString() });
118
+ console.log(`${key}: ${files.path('cast', `${key}.png`)} · no rig: ${reason}; moves as one cut-out (cast_animate gives it a 3D model)`);
119
+ }
120
+ }
121
+
122
+ export const rigAttemptFile = (files: WorldFiles, key: string) => files.path('cast', key, 'rig-attempt.json');
123
+
124
+ defineCommand({
125
+ 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',
127
+ async run(args) {
128
+ const [slug, ...only] = args;
129
+ if (!slug) throw new Error('usage: diorama rig <slug> [key...]');
130
+ const files = new WorldFiles(slug);
131
+ const bodies = readBodies(files, fs.existsSync);
132
+ 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
+ for (const key of keys) {
134
+ if (bodies[key]?.upright === false) continue;
135
+ 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);
139
+ }
140
+ console.log(`calls sent: sam ${files.memo.sent.sam}`);
141
+ },
142
+ });