@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,117 @@
1
+ // diorama masks <slug> [place...]: masks/<thing>.png, masks/floors/<floor>.png and masks.json per place.
2
+ import fs from 'node:fs';
3
+ import { maskFromPng, maskToPng, type Mask } from '../image/mask.ts';
4
+ import { PALETTE, svgLabel, tintOver } from '../image/overlay.ts';
5
+ import { defineCommand } from '../registry.ts';
6
+ import { Brief } from '../scene/brief.ts';
7
+ import { carryFrom, ran, remakes, tilesLine } from '../scene/carry.ts';
8
+ import { brushed, correctCells, correctSpots, readCorrections } from '../scene/corrections.ts';
9
+ import { Layout } from '../scene/layout.ts';
10
+ import { locateThings, markFloors } from '../scene/locate.ts';
11
+ import { checkMasks, type MaskCheck } from '../scene/maskcheck.ts';
12
+ import { maskFloors, maskThings } from '../scene/masks.ts';
13
+ import { paintingLayoutHash } from '../scene/paint-layout.ts';
14
+ import { maskBox, type Rect } from '../image/raster.ts';
15
+ import { WorldFiles } from '../world/files.ts';
16
+
17
+ defineCommand({
18
+ name: 'masks',
19
+ usage: 'masks <slug> [place...] find each thing and floor in the painting with Spark and SAM 3.1',
20
+ async run([slug, ...only]) {
21
+ if (!slug) throw new Error('usage: diorama masks <slug> [place...]');
22
+ const files = new WorldFiles(slug);
23
+ const world = files.world();
24
+ const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
25
+ await Promise.all(places.map(async (place) => {
26
+ const at = (...parts: string[]) => files.place(place.key, ...parts);
27
+ fs.rmSync(at('verify.json'), { force: true });
28
+ const layout = Layout.parse(files.readJson(at('layout.json')));
29
+ const brief = Brief.parse(files.readJson(at('brief.json')));
30
+ const painting = fs.readFileSync(at('painting.png'));
31
+ // A rejected/stale checked painting must not be rescued by masks and invented floor strokes.
32
+ // Legacy worlds without a report remain editable; rebuilding from paint checks them too.
33
+ if (!fs.existsSync(at('photo.png')) && fs.existsSync(at('painting-layout.json'))) {
34
+ const report = files.readJson(at('painting-layout.json')) as { ok?: boolean; inputs?: string };
35
+ if (!report.ok || report.inputs !== paintingLayoutHash(place, layout, fs.readFileSync(at('blockin.png')), painting)) {
36
+ throw new Error(`PAINT_LAYOUT_MISMATCH ${place.key}: painting/layout changed or its composition check failed; rebuild this place from paint to inspect and repair it before masks`);
37
+ }
38
+ }
39
+ const flagged: string[] = [];
40
+ const log = (line: string) => {
41
+ flagged.push(line);
42
+ console.log(` ${place.key}: ${line}`);
43
+ };
44
+
45
+ // After a small repaint, what it did not reach is found again on the old painting: the memo has those answers.
46
+ const carry = await carryFrom(at(), 'masks', painting);
47
+ if (carry) console.log(` ${place.key}: ${tilesLine(carry)}; only what it reaches is asked again`);
48
+ const seen = carry?.old ?? painting;
49
+ const [found, marked] = await Promise.all([
50
+ locateThings(files.memo, place, brief, seen),
51
+ markFloors(files.memo, place, layout, brief, seen),
52
+ ]);
53
+ files.write(at('locate.json'), { things: found, floors: marked });
54
+ // The person's fixes go in before SAM is asked and their brush strokes after, every time.
55
+ const fixes = readCorrections(at());
56
+ const spots = correctSpots(found, fixes);
57
+ const cells = correctCells(marked, fixes);
58
+ const before = async (file: string): Promise<Mask | undefined> => (carry && fs.existsSync(at('masks', file)) ? maskFromPng(fs.readFileSync(at('masks', file))) : undefined);
59
+ // A thing or floor keeps its mask when the change does not reach it, or only grazes a big one.
60
+ const keptThing = new Set<string>();
61
+ const keptFloor = new Set<string>();
62
+ for (const t of place.things) {
63
+ const last = await before(`${t.key}.png`);
64
+ if (carry && last && !remakes(carry, last)) keptThing.add(t.key);
65
+ }
66
+ for (const f of layout.floors) {
67
+ const last = await before(`floors/${f.key}.png`);
68
+ if (carry && last && !remakes(carry, last)) keptFloor.add(f.key);
69
+ }
70
+ const split = <T>(all: T[], kept: (x: T) => boolean) => [all.filter(kept), all.filter((x) => !kept(x))];
71
+ const [oldThings, newThings] = split(place.things, (t) => keptThing.has(t.key));
72
+ const [oldFloors, newFloors] = split(layout.floors, (f) => keptFloor.has(f.key));
73
+ const [thingsOld, thingsNew, floorsOld, floorsNew] = await Promise.all([
74
+ oldThings.some((t) => t.role !== 'npc') ? maskThings(files.memo, { ...place, things: oldThings }, layout, spots, carry!.old, log) : [],
75
+ newThings.some((t) => t.role !== 'npc') ? maskThings(files.memo, { ...place, things: newThings }, layout, spots, painting, log) : [],
76
+ oldFloors.length ? maskFloors(files.memo, place, { ...layout, floors: oldFloors }, cells, carry!.old, log) : [],
77
+ newFloors.length ? maskFloors(files.memo, place, { ...layout, floors: newFloors }, cells, painting, log) : [],
78
+ ]);
79
+ const inOrder = <T extends { key: string }>(keys: string[], got: T[]) => keys.flatMap((k) => got.filter((g) => g.key === k));
80
+ const things = inOrder(place.things.map((t) => t.key), [...thingsOld, ...thingsNew]).map((t) => brushed(t, fixes.things[t.key]?.strokes));
81
+ const floors = inOrder(layout.floors.map((f) => f.key), [...floorsOld, ...floorsNew]).map((f) => brushed(f, fixes.floors[f.key]?.strokes));
82
+ if (carry) console.log(` ${place.key}: kept ${thingsOld.length}/${things.length} things and ${floorsOld.length}/${floors.length} floors as they were; masked again: ${[...thingsNew, ...floorsNew].map((m) => m.key).join(', ') || 'nothing'}`);
83
+ const lastChecks = carry && fs.existsSync(at('masks.json')) ? ((files.readJson(at('masks.json')) as { things?: Record<string, { box?: Rect; check?: MaskCheck }> }).things ?? {}) : {};
84
+
85
+ fs.rmSync(at('masks'), { recursive: true, force: true });
86
+ for (const t of things) files.write(at('masks', `${t.key}.png`), await maskToPng(t.mask));
87
+ for (const f of floors) files.write(at('masks', 'floors', `${f.key}.png`), await maskToPng(f.mask));
88
+ const layers = [...floors.map((f) => ({ key: f.key, mask: f.mask, alpha: 0.25 })), ...things.map((t) => ({ key: t.key, mask: t.mask, alpha: 0.45 }))];
89
+ const tints = layers.map((l, i) => ({ mask: l.mask, alpha: l.alpha, rgb: PALETTE[i % PALETTE.length] }));
90
+ const labels = [...floors.map((f) => ({ key: f.key, box: maskBox(f.mask) })), ...things.map((t) => ({ key: t.key, box: t.box as Rect | undefined }))]
91
+ .map((l, i) => (l.box ? svgLabel(l.box[0] + 4, l.box[1] + 18, l.key, PALETTE[i % PALETTE.length]) : ''));
92
+ files.write(at('masks', 'overlay.png'), await tintOver(painting, tints, labels.join('')));
93
+ // A corrected box guides SAM; it does not prove what SAM cut out. Kept masks retain their check.
94
+ let checks: Record<string, MaskCheck> = Object.fromEntries(things.flatMap((t) => (keptThing.has(t.key) && lastChecks[t.key]?.check && JSON.stringify(t.box) === JSON.stringify(lastChecks[t.key].box) ? [[t.key, lastChecks[t.key].check!]] : [])));
95
+ try {
96
+ const look = things.filter((t) => !checks[t.key]);
97
+ // Asked by what the thing is (the world's words), not by how the brief dressed it: a brief that described
98
+ // "the empty stall the cow uses" let a mask on an empty stall pass.
99
+ if (look.length) checks = { ...checks, ...await checkMasks(files.memo, place.key, painting, look.map((t) => ({ key: t.key, looks: place.things.find((x) => x.key === t.key)?.looks ?? brief.looks[t.key] ?? t.key, box: t.box as Rect | undefined }))) };
100
+ for (const [key, c] of Object.entries(checks)) if (!c.ok) log(`${key}: Spark sees ${c.seen ?? 'something else'} in its mask`);
101
+ } catch (error) {
102
+ throw new Error(`mask check failed: ${error instanceof Error ? error.message : error}`);
103
+ }
104
+ const round = (v: number) => Math.round(v * 100) / 100;
105
+ files.write(at('masks.json'), {
106
+ things: Object.fromEntries(things.map(({ key, mask: _, ...t }) => [key, { ...t, ...(checks[key] && { check: checks[key] }) }])),
107
+ floors: Object.fromEntries(floors.map(({ key, mask: _, iouCells, iouLayout, ...f }) => [key, { ...f, iouCells: round(iouCells), iouLayout: round(iouLayout) }])),
108
+ flagged,
109
+ });
110
+ // After a carry the old painting stays the one the answers belong to, so a rerun on this painting asks nothing new.
111
+ ran(at(), 'masks', carry?.old ?? painting);
112
+ const coarse = [...things, ...floors].filter((m) => m.coarse).map((m) => m.key);
113
+ console.log(`${place.key}: ${things.length} things, ${floors.length} floors${coarse.length ? `, coarse: ${coarse.join(', ')}` : ''}`);
114
+ }));
115
+ console.log(`calls sent: spark ${files.memo.sent.spark}, sam ${files.memo.sent.sam}`);
116
+ },
117
+ });
@@ -0,0 +1,52 @@
1
+ // diorama mcp: the Studio's actions as an MCP server over stdio, for Muse Code (or any MCP client) started
2
+ // by hand. With a Studio running for this folder it forwards every call there, so the page shows the agent's
3
+ // work live; without one it runs the actions itself.
4
+ import path from 'node:path';
5
+ import readline from 'node:readline';
6
+ import '../actions/index.ts';
7
+ import { defineCommand } from '../registry.ts';
8
+ import { Events } from '../studio/events.ts';
9
+ import { snapshotBeforeJob } from '../studio/history.ts';
10
+ import { Jobs } from '../studio/jobs.ts';
11
+ import { handleMcp, type Reply } from '../studio/mcp.ts';
12
+ import { readStudioFile } from '../studio/discovery.ts';
13
+
14
+ defineCommand({
15
+ name: 'mcp',
16
+ usage: 'mcp serve the Studio actions as MCP tools on stdio (forwards to a running diorama studio)',
17
+ async run() {
18
+ const studio = readStudioFile(process.cwd());
19
+ const events = new Events();
20
+ const root = path.resolve('worlds');
21
+ const ctx = { by: 'agent' as const, root, events, jobs: new Jobs(events, { beforeStart: snapshotBeforeJob(root, events) }) };
22
+ const out = (reply: Reply | Reply[] | null) => reply && process.stdout.write(`${JSON.stringify(reply)}\n`);
23
+ // Logs go to stderr: stdout carries only protocol messages.
24
+ console.error(studio ? `diorama mcp: forwarding to the Studio at ${studio.url}` : 'diorama mcp: no Studio running here, running actions in this process');
25
+ const lines = readline.createInterface({ input: process.stdin });
26
+ for await (const line of lines) {
27
+ if (!line.trim()) continue;
28
+ let message: unknown;
29
+ try {
30
+ message = JSON.parse(line);
31
+ } catch {
32
+ out({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
33
+ continue;
34
+ }
35
+ if (studio) {
36
+ const res = await fetch(`${studio.url}mcp`, {
37
+ method: 'POST',
38
+ headers: { authorization: `Bearer ${studio.agentToken}`, 'content-type': 'application/json' },
39
+ body: line,
40
+ }).catch((error: Error) => ({ ok: false, status: 0, text: async () => error.message }));
41
+ if (res.status === 202) continue;
42
+ const text = await res.text();
43
+ if (res.ok) process.stdout.write(`${text}\n`);
44
+ else out({ jsonrpc: '2.0', id: (message as { id?: string | number }).id ?? null, error: { code: -32603, message: `the Studio answered ${res.status}: ${text.slice(0, 200)}` } });
45
+ continue;
46
+ }
47
+ if (Array.isArray(message)) out((await Promise.all(message.map((m) => handleMcp(m, ctx)))).filter((r): r is Reply => !!r));
48
+ else out(await handleMcp(message as Parameters<typeof handleMcp>[0], ctx));
49
+ }
50
+ ctx.jobs.stopAll();
51
+ },
52
+ });
@@ -0,0 +1,284 @@
1
+ // diorama model <slug> [key]: a character as a rigged 3D model, painted by Muse from four sides. Without a
2
+ // key, everyone the sprites plan needs (the hero, NPCs who speak): the model is where their poses come from.
3
+ import { castLanes, mapLimit } from '../world/pool.ts';
4
+ import fs from 'node:fs';
5
+ import { bindExistingAnimation, bindAnimation } from '../scene/animation-source.ts';
6
+ import { refusedByFilter } from '../scene/words.ts';
7
+ import { defineCommand, registry, takeOption } from '../registry.ts';
8
+ import { autoRig, reconstruct } from '../model/build.ts';
9
+ import { fitViews, refitIfOld } from '../model/fit.ts';
10
+ import { badModel, lean, readShape, rigProblem, standUp } from '../model/glb.ts';
11
+ import { findBlender, relaxPose, type RelaxReport } from '../model/blender.ts';
12
+ import { paintViews } from '../model/views.ts';
13
+ import { planSprites } from '../model/plan.ts';
14
+ import { animationSettings, animationOptions, saveAnimationSettings } from '../model/animation.ts';
15
+ import { Story } from '../story/schema.ts';
16
+ import { fixedLooks, readCastFixes } from '../scene/cast-fixes.ts';
17
+ import { readBodies } from '../scene/cast.ts';
18
+ import { WorldFiles } from '../world/files.ts';
19
+
20
+ defineCommand({
21
+ name: 'model',
22
+ usage: 'model <slug> [key] with [key], also finishes its required sprites; Muse paints each character who needs sprites (or [key]) from 4 sides, TRELLIS + auto-rig make a 3D model (cast/<key>/model/) [--texture model|views: saved per character] [--glb rigged.glb: skip TRELLIS and the rig] [--coat-correction on|off: off for trousers] [--sprites: finish the character animation, used by Studio] [--take N] [--refit: only fit the views onto the model again, no calls] [--relax: only put existing models into a calm rest pose with a local Blender, no calls]',
23
+ async run(args) {
24
+ const options = animationOptions(takeOption(args, 'texture', ''), '', takeOption(args, 'coat-correction', ''));
25
+ const take = Number(takeOption(args, 'take', '0'));
26
+ const given = takeOption(args, 'glb', '');
27
+ const refit = args.includes('--refit');
28
+ const relaxOnly = args.includes('--relax');
29
+ const [slug, keyArg] = args.filter((a) => a !== '--refit' && a !== '--sprites' && a !== '--relax');
30
+ // An individual Animate request includes its sprites, including older Studio clients.
31
+ const withSprites = !!keyArg || args.includes('--sprites');
32
+ if (!slug) throw new Error('usage: diorama model <slug> [key] [--glb rigged.glb] [--refit]');
33
+ if (refit) {
34
+ if (Object.keys(options).length) throw new Error('--texture cannot be combined with --refit');
35
+ const files = new WorldFiles(slug);
36
+ const castDir = files.path('cast');
37
+ for (const key of keyArg ? [keyArg] : fs.readdirSync(castDir).filter((k) => fs.existsSync(files.path('cast', k, 'model', 'model.json')))) {
38
+ await refitIfOld(files.path('cast', key, 'model'), true);
39
+ console.log(`${key}: views fitted again, slice by slice`);
40
+ }
41
+ return;
42
+ }
43
+ if (relaxOnly) {
44
+ const blender = await findBlender();
45
+ if (!blender) throw new Error('--relax needs Blender 3.6+ (install it, or set DIORAMA_BLENDER to its path)');
46
+ const files = new WorldFiles(slug);
47
+ const castDir = files.path('cast');
48
+ const keys = keyArg ? [keyArg] : fs.readdirSync(castDir).filter((k) => fs.existsSync(files.path('cast', k, 'model', 'model.json')));
49
+ for (const key of keys) console.log(`${key}: ${await relaxModel(files, key, true)}`);
50
+ // Renders of the model itself follow it; painted sprites are redrawn by diorama sprites (Muse).
51
+ for (const key of keys) if (animationSettings(files, key).texture === 'model') await registry.get('sprites')!.run([slug, key]);
52
+ return;
53
+ }
54
+ if (given && !keyArg) throw new Error('--glb needs the key of the character it is');
55
+ const files = new WorldFiles(slug);
56
+ const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : undefined;
57
+ // Everyone the sprites plan needs, and whoever the person asked to animate (cast_animate, cast/fixes.json).
58
+ const asked = Object.entries(readCastFixes(files)).filter(([, f]) => f.animate).map(([k]) => k);
59
+ const keys = keyArg ? [keyArg] : [...new Set([...(await planSprites(files, story)).map((p) => p.key), ...asked])];
60
+ for (const key of keys) if (Object.keys(options).length) saveAnimationSettings(files, key, animationSettings(files, key, options));
61
+ await buildModels(files, keys, take, given, !!keyArg);
62
+ console.log(`calls sent: muse ${files.memo.sent.muse}, space ${files.memo.sent.space}`);
63
+ // UV sprites are renders of the model itself (no Muse): a new model without them leaves the game walking
64
+ // the old body's sheets (a pole the new sides left out, an old face), so they are always rendered again.
65
+ const uvSprites = (key: string) => {
66
+ try {
67
+ return animationSettings(files, key).texture === 'model' && fs.existsSync(files.path('cast', key, 'model', 'model.json'));
68
+ } catch {
69
+ return false;
70
+ }
71
+ };
72
+ if (withSprites || keys.some(uvSprites)) {
73
+ const plans = await planSprites(files, story);
74
+ for (const key of keys) if ((withSprites || uvSprites(key)) && plans.some((p) => p.key === key && !p.creature)) await registry.get('sprites')!.run([slug, key]);
75
+ }
76
+ },
77
+ });
78
+
79
+ /** Said when a character is left in 2D because the 3D service did not answer: cast_animate tries it again. */
80
+ export const SERVICE_DOWN = 'the 3D service did not answer';
81
+ /** Pauses before asking TRELLIS again after an error; tests shorten them. */
82
+ export const SERVICE_PAUSES = { ms: [10_000, 30_000, 60_000] };
83
+ /** Errors that asking again will not mend: no quota left, no access, a refused picture. */
84
+ const lasting = (message: string) => /quota|\b40[13]\b|unauthori[sz]ed|forbidden|NSFW|not allowed/i.test(message);
85
+
86
+ /** An unavailable service for one character must not prevent the rest of the cast being attempted. */
87
+ export async function buildModels(files: WorldFiles, keys: string[], take: number, given: string, requireModel = false) {
88
+ const failures: string[] = [];
89
+ // Characters side by side (a few at once): each waits on Muse, Claude and the Spaces, never on another character.
90
+ // The Spaces keep their own small lanes (model/build.ts), so a busy GPU queue is not asked twice as hard.
91
+ await mapLimit(keys, castLanes(), async (key) => {
92
+ try {
93
+ const made = await buildModel(files, key, take, given);
94
+ if (!made && requireModel) failures.push(`${key}: no usable 3D model (${attemptReason(files, key)}); see cast status`);
95
+ } catch (error) {
96
+ const reason = `${key}: ${error instanceof Error ? error.message : String(error)}`;
97
+ console.error(reason);
98
+ failures.push(reason);
99
+ }
100
+ });
101
+ if (failures.length) throw new Error(`3D models failed: ${failures.join('; ')}`);
102
+ }
103
+
104
+ function attemptReason(files: WorldFiles, key: string): string {
105
+ try {
106
+ return String((files.readJson(files.path('cast', key, 'model', 'attempt.json')) as { reason?: unknown }).reason ?? 'unknown');
107
+ } catch {
108
+ return 'unknown';
109
+ }
110
+ }
111
+
112
+ export async function buildModel(files: WorldFiles, key: string, take: number, given: string): Promise<boolean> {
113
+ const attempt = files.path('cast', key, 'model', 'attempt.json');
114
+ bindExistingAnimation(files, key);
115
+ try {
116
+ const reason = await makeModel(files, key, take, given);
117
+ if (!reason) bindAnimation(files, key, 'model');
118
+ files.write(attempt, { state: reason ? 'fallback' : 'ready', reason, at: new Date().toISOString() });
119
+ return !reason;
120
+ } catch (error) {
121
+ files.write(attempt, { state: 'failed', reason: error instanceof Error ? error.message : String(error), at: new Date().toISOString() });
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ async function makeModel(files: WorldFiles, key: string, take: number, given: string): Promise<string | null> {
127
+ const finishSettings = (mesh: Buffer) => {
128
+ const settings = animationSettings(files, key, {}, mesh);
129
+ saveAnimationSettings(files, key, settings);
130
+ return { ...(settings.texture === 'model' && { texture: 'model' as const }), ...(settings.coatCorrection !== undefined && { coatCorrection: settings.coatCorrection }) };
131
+ };
132
+ const world = files.world();
133
+ const found = [world.hero, ...world.places.flatMap((p) => p.things)].find((w) => w.key === key);
134
+ if (!found) throw new Error(`no hero or NPC called ${key}`);
135
+ const supplied = given ? fs.readFileSync(given) : undefined;
136
+ if (supplied) {
137
+ const bad = badModel(readShape(supplied));
138
+ if (bad) throw new Error(`${key}: supplied 3D model rejected: ${bad}`);
139
+ }
140
+ const who = fixedLooks(files, found);
141
+ const painting = fs.readFileSync(files.path('cast', `${key}-painting.png`));
142
+ const out = (name: string) => files.path('cast', key, 'model', name);
143
+
144
+ // Not a person on two legs (cast/figures.json): painted as it is, no skeleton; it hops and squashes as one body.
145
+ const creature = readBodies(files, fs.existsSync)[key]?.upright === false;
146
+ // Each stage says its name ("<key>: ..."), and each side is written as it lands: Studio follows both live.
147
+ console.log(`${key}: painting 4 sides`);
148
+ let views: Awaited<ReturnType<typeof paintViews>>;
149
+ try {
150
+ views = await paintViews(files.memo, key, who.looks ?? '', painting, take, creature, (name, png) => files.write(out(`${name}.png`), png));
151
+ } catch (error) {
152
+ // Refused even in calm words: the model made before stays (a changed prompt reran it), else the 2D rig walks.
153
+ if (!refusedByFilter(error)) throw error;
154
+ if (fs.existsSync(out('model.json')) && fs.existsSync(out('model.glb'))) {
155
+ console.log(`${key}: the image safety filter refused its 4 sides; the 3D model made before stays`);
156
+ return null;
157
+ }
158
+ console.log(`${key}: no 3D model: the image safety filter refused its 4 sides; ${creature ? 'the painted figure hops as it is' : 'the game walks the 2D rig'}`);
159
+ return 'the image safety filter refused its 4 sides';
160
+ }
161
+ // TRELLIS sometimes makes a flat card of a flat drawing: other seeds, then the front alone (depth guessed),
162
+ // and with none of them a body, no model: the character walks with its 2D rig.
163
+ const all = [views.front, views.back, views.left, views.right];
164
+ const tries = [[1, all], [2, all], [3, all], [1, [views.front]]] as const;
165
+ let shell: Buffer | undefined;
166
+ // A Space that answers with an error (busy, restarting, a lost file) is asked again after a pause, on the next try;
167
+ // one that keeps failing leaves the character in 2D, so the world is still built and playable.
168
+ let unavailable: string | undefined;
169
+ let failures = 0;
170
+ let built: Buffer | undefined;
171
+ let rigDown: string | undefined;
172
+ let misplaced: string | undefined;
173
+ for (const [seed, from] of tries) {
174
+ if (given) break;
175
+ console.log(`${key}: TRELLIS builds the body${seed > 1 || from.length === 1 ? ` (try ${seed + (from.length === 1 ? 3 : 0)})` : ''}`);
176
+ let made: Buffer;
177
+ try {
178
+ made = await reconstruct(files.memo, key, [...from], seed);
179
+ } catch (error) {
180
+ unavailable = error instanceof Error ? error.message : String(error);
181
+ console.log(`${key}: TRELLIS failed: ${unavailable}`);
182
+ if (lasting(unavailable) || ++failures > SERVICE_PAUSES.ms.length) break;
183
+ const pause = SERVICE_PAUSES.ms[failures - 1];
184
+ console.log(`${key}: asking TRELLIS again in ${Math.round(pause / 1000)} s`);
185
+ await new Promise((resolve) => setTimeout(resolve, pause));
186
+ continue;
187
+ }
188
+ unavailable = undefined;
189
+ const bad = badModel(readShape(made));
190
+ if (bad) {
191
+ console.log(`${key}: TRELLIS (seed ${seed}${from.length === 1 ? ', front only' : ''}) made a model that is ${bad}`);
192
+ continue;
193
+ }
194
+ if (creature) { shell = made; break; }
195
+ // The auto-rig guesses joints from the shape alone and is deterministic: a skeleton it misplaces (a long
196
+ // robe hides the legs) comes back the same for the same body, so the next TRELLIS body gets the next try.
197
+ console.log(`${key}: rigging the skeleton`);
198
+ let skinned: Buffer;
199
+ try {
200
+ skinned = await autoRig(files.memo, key, made);
201
+ } catch (error) {
202
+ rigDown = error instanceof Error ? error.message : String(error);
203
+ break;
204
+ }
205
+ misplaced = rigProblem(skinned) ?? undefined;
206
+ if (!misplaced) { shell = made; built = skinned; break; }
207
+ console.log(`${key}: auto-rig on TRELLIS seed ${seed}${from.length === 1 ? ', front only' : ''}: ${misplaced}`);
208
+ }
209
+ const rejected = out('model.json');
210
+ const reject = (why: string) => {
211
+ if (fs.existsSync(rejected)) fs.renameSync(rejected, out('model-rejected.json'));
212
+ console.log(`${key}: no 3D model: ${why}; ${creature ? 'the painted figure hops as it is' : 'the game walks the 2D rig'}`);
213
+ return why;
214
+ };
215
+ if (rigDown) return reject(`${SERVICE_DOWN}: ${rigDown}`);
216
+ if (!given && !shell) {
217
+ // An older model of this character (a flat one, kept before this gate) must not stay in the game.
218
+ return reject(unavailable ? `${SERVICE_DOWN}: ${unavailable}` : misplaced ? `every skeleton the auto-rig made was misplaced (${misplaced})` : 'every reconstruction was flat or invalid');
219
+ }
220
+ if (creature) {
221
+ // Make-It-Animatable rigs a person's skeleton only; a creature keeps TRELLIS's mesh as it came (no standing up:
222
+ // a cat or a frog is not meant to be straight up and down).
223
+ const mesh = supplied ?? shell!;
224
+ files.write(out('model.glb'), mesh);
225
+ const fits = await fitViews(mesh, views);
226
+ files.write(out('model.json'), { glb: 'model.glb', creature: true, views: fits, ...finishSettings(mesh) });
227
+ console.log(`${key}: ${out('model.glb')} · a creature, no skeleton: hops, squashes and blinks as one body · views ${Object.entries(fits).map(([v, f]) => `${v} ${f.iou}`).join(', ')}`);
228
+ return null;
229
+ }
230
+ built ??= supplied!;
231
+ const misrigged = rigProblem(built);
232
+ if (misrigged) return reject(`rigged body rejected: ${misrigged}`);
233
+ const bad = badModel(readShape(built));
234
+ if (bad) return reject(`rigged body rejected: ${bad}`);
235
+ const tilt = lean(readShape(built));
236
+ const rigged = standUp(built);
237
+ const standingProblem = badModel(readShape(rigged));
238
+ if (standingProblem) return reject(`standing body rejected: ${standingProblem}`);
239
+ files.write(out('model.glb'), rigged);
240
+
241
+ const fits = await fitViews(rigged, views);
242
+ files.write(out('model.json'), { glb: 'model.glb', views: fits, ...finishSettings(rigged) });
243
+ console.log(`${key}: ${await relaxModel(files, key)}`);
244
+ const degrees = (slope: number) => Math.round(Math.atan(slope) * 180 / Math.PI * 10) / 10;
245
+ console.log(`${key}: ${out('model.glb')} · stood up ${degrees(tilt.z)}° forward, ${degrees(tilt.x)}° sideways · views ${Object.entries(fits).map(([v, f]) => `${v} ${f.iou}`).join(', ')}`);
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * What the Blender round trip broke, or null. The face is painted onto the surface from the views, so a head
251
+ * that moved, or a mesh whose corners came apart (flat shading splits every triangle on export: faceted
252
+ * normals, a blotchy torn face on the talk sprites), must not replace the rig that worked.
253
+ */
254
+ export function relaxDamage(source: Buffer, relaxed: Buffer, report: RelaxReport): string | null {
255
+ if ((report.headMove ?? 0) > 0.002) return `the head moved ${(report.headMove! * 100).toFixed(2)}% of the height`;
256
+ const before = readShape(source).positions.length / 3, after = readShape(relaxed).positions.length / 3;
257
+ if (after > before * 1.3) return `the mesh came apart on export (${before} -> ${after} corners)`;
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * With a local Blender, the model's rest pose becomes calm (arms down, feet under the hips): the game's walk
263
+ * then turns bones a little from rest instead of 100 degrees from TRELLIS's raised arms, which pinched the
264
+ * shoulders, glued sleeves to the chest and splayed the hands. model.source.glb keeps the rig as it came, so
265
+ * this can run again; model.json says relaxed. Says what happened, in a line.
266
+ */
267
+ export async function relaxModel(files: WorldFiles, key: string, again = false): Promise<string> {
268
+ const out = (name: string) => files.path('cast', key, 'model', name);
269
+ const info = files.readJson(out('model.json')) as { glb: string; relaxed?: boolean; creature?: boolean };
270
+ if (info.creature) return 'a creature has no skeleton to relax';
271
+ if (info.relaxed && !again) return 'rest pose already relaxed';
272
+ const blender = await findBlender();
273
+ if (!blender) return 'rest pose as TRELLIS built it (no local Blender; install Blender 3.6+ for calmer walks)';
274
+ const source = fs.existsSync(out('model.source.glb')) ? fs.readFileSync(out('model.source.glb')) : fs.readFileSync(out(info.glb));
275
+ const done = await relaxPose(source, (line) => console.log(`${key}: ${line}`));
276
+ if (!done) return 'rest pose as TRELLIS built it (Blender could not relax it)';
277
+ const problem = rigProblem(done.glb) ?? badModel(readShape(done.glb)) ?? relaxDamage(source, done.glb, done.report);
278
+ if (problem) return `rest pose as TRELLIS built it (relaxed body rejected: ${problem})`;
279
+ if (!fs.existsSync(out('model.source.glb'))) files.write(out('model.source.glb'), source);
280
+ files.write(out('model.glb'), done.glb);
281
+ files.write(out('model.json'), { ...info, glb: 'model.glb', relaxed: true });
282
+ const arms = Object.values(done.report.armTurn ?? {}).map(Math.round).join('/');
283
+ return `Blender ${blender.version} relaxed the rest pose (arms turned ${arms}° down, feet under the hips)`;
284
+ }
@@ -0,0 +1,182 @@
1
+ import { worldStyleReference } from '../world/style-reference.ts';
2
+ // diorama paint <slug> [place...]: painting.png for each laid-out place.
3
+ import { createHash } from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import { writeBrief } from '../scene/brief.ts';
6
+ import { seedRan } from '../scene/carry.ts';
7
+ import { addsPerson, critique, paintedPeople, repaint } from '../scene/critic.ts';
8
+ import { readCorrections, writeCorrections } from '../scene/corrections.ts';
9
+ import { Layout } from '../scene/layout.ts';
10
+ import { paintPlace } from '../scene/paint.ts';
11
+ import { alignPainting, PaintingLayoutError, paintingLayoutHash } from '../scene/paint-layout.ts';
12
+ import { paintPhoto } from '../scene/photo.ts';
13
+ import { restylePlace } from '../scene/restyle.ts';
14
+ import { refusedByFilter, reword, withWords, worded, type Words } from '../scene/words.ts';
15
+ import { defineCommand, takeOption } from '../registry.ts';
16
+ import { drawingsIn } from '../world/drawings.ts';
17
+ import { WorldFiles } from '../world/files.ts';
18
+ import { approvedConcept } from '../world/sketch.ts';
19
+ import { markStyled, placeStyle, readStyled } from '../world/styled.ts';
20
+
21
+ /** Rewords Spark may try after the image safety filter refuses a painting, before the place is set aside. */
22
+ const REWORDS = 2;
23
+
24
+ defineCommand({
25
+ name: 'paint',
26
+ usage: 'paint <slug> [place...] brief, paint, critique and repaint laid-out places [--take N]',
27
+ async run(args) {
28
+ const takeArg = takeOption(args, 'take', '');
29
+ const [slug, ...only] = args;
30
+ if (!slug) throw new Error('usage: diorama paint <slug> [place...] [--take N]');
31
+ const files = new WorldFiles(slug);
32
+ const base = files.world();
33
+ const places = base.places.filter((p) => only.length === 0 || only.includes(p.key));
34
+ await Promise.all(places.map(async (place) => {
35
+ // The place's own style, when the person gave it one, is the world's style for everything painted here.
36
+ const style = placeStyle(files, base, place.key);
37
+ // --take wins; else the take the place's corrections keep (a new take the build asked for stays across rebuilds).
38
+ const take = takeArg ? Number(takeArg) : readCorrections(files.place(place.key)).take ?? 0;
39
+ const world = { ...base, style };
40
+ const layout = Layout.parse(files.readJson(files.place(place.key, 'layout.json')));
41
+ const blockin = fs.readFileSync(files.place(place.key, 'blockin.png'));
42
+ const fromPhoto = fs.existsSync(files.place(place.key, 'photo.png'));
43
+ const concept = approvedConcept(files, 'place', place.key);
44
+ const styleReference = worldStyleReference(files, style);
45
+ // The first painting is kept while its block-in, layout and take stay the same, even without the model
46
+ // cache (a world copied without memo/): a person's region repaint must not repaint the whole place.
47
+ const hash = (words?: Words) => {
48
+ const h = createHash('sha256').update(blockin).update(JSON.stringify(layout)).update(String(take));
49
+ // A new wording paints the place anew; a place never reworded keeps the inputs it had.
50
+ if (words) h.update(JSON.stringify(words));
51
+ if (concept) h.update(concept);
52
+ if (styleReference) h.update(styleReference);
53
+ return h.digest('hex');
54
+ };
55
+ let words = readCorrections(files.place(place.key)).words;
56
+ let inputs = hash(words);
57
+ const kept = files.place(place.key, 'painting-first.json');
58
+ const mtime = (f: string) => fs.statSync(files.place(place.key, f)).mtimeMs;
59
+ const have = ['painting-first.png', 'brief.json', 'critique.json'].every((f) => fs.existsSync(files.place(place.key, f)));
60
+ // Worlds painted before this record: the first painting counts when it is newer than what it was painted from.
61
+ const same = fs.existsSync(kept)
62
+ ? (files.readJson(kept) as { inputs?: string }).inputs === inputs
63
+ : have && take === 0 && mtime('painting-first.png') >= Math.max(mtime('blockin.png'), mtime('layout.json'));
64
+ const reuse = have && same;
65
+ const record = (fs.existsSync(kept) ? files.readJson(kept) : {}) as { inputs?: string; applied?: string[] };
66
+ let first: Buffer;
67
+ let found: Awaited<ReturnType<typeof critique>>;
68
+ // The style changed since this place was painted (style_set): Muse repaints the painting as it is, every
69
+ // fix in it, in the new style, keeping the composition, so masks, stand spots and the person's fixes still fit.
70
+ const was = readStyled(files).places[place.key];
71
+ const restyled = reuse && !!was && was !== style && fs.existsSync(files.place(place.key, 'painting.png'));
72
+ if (restyled) {
73
+ const brief = files.readJson(files.place(place.key, 'brief.json')) as Awaited<ReturnType<typeof writeBrief>>;
74
+ const told = worded(place, words);
75
+ first = await restylePlace(files.memo, told, fs.readFileSync(files.place(place.key, 'painting.png')), was, style, brief, take, drawingsIn(files, place));
76
+ console.log(` ${place.key}: repainted in the new style, composition kept`);
77
+ files.write(files.place(place.key, 'painting-first.png'), first);
78
+ found = await critique(files.memo, told, brief, first);
79
+ files.write(files.place(place.key, 'critique.json'), found);
80
+ } else if (reuse) {
81
+ first = fs.readFileSync(files.place(place.key, 'painting-first.png'));
82
+ found = files.readJson(files.place(place.key, 'critique.json')) as typeof found;
83
+ // The critic is asked again (a memo hit while its prompt stays the same): what it now finds unwanted (a person
84
+ // painted in, a double of the place's NPC) is painted out over the painting as it is; the rest stays.
85
+ const brief = files.readJson(files.place(place.key, 'brief.json')) as Awaited<ReturnType<typeof writeBrief>>;
86
+ // A person the critic itself once asked for ("a child kneeling at the dock edge") is taken out of its fixes,
87
+ // so the painting is made again from the first one without them.
88
+ const before = found.issues.length;
89
+ found = { issues: found.issues.filter((i) => !addsPerson(i)) };
90
+ const again = await critique(files.memo, worded(place, words), brief, first);
91
+ // People are looked for on the painting as the game shows it, with every fix in, not only the first one.
92
+ const shown = fs.existsSync(files.place(place.key, 'painting.png')) ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
93
+ for (const p of shown === first ? [] : await paintedPeople(files.memo, place.key, shown)) {
94
+ again.issues.push({ key: 'person', problem: 'unwanted', region: p.box, fix: `Paint out the ${p.what}: continue what is behind them (planks, water, wall), add nothing new` });
95
+ }
96
+ const had = new Set(found.issues.map((i) => JSON.stringify([i.region, i.fix])));
97
+ const unwanted = again.issues.filter((i) => i.problem === 'unwanted' && !had.has(JSON.stringify([i.region, i.fix])));
98
+ if (unwanted.length || found.issues.length !== before) {
99
+ found = { issues: [...found.issues, ...unwanted].slice(0, 8) };
100
+ files.write(files.place(place.key, 'critique.json'), found);
101
+ }
102
+ } else {
103
+ let brief: Awaited<ReturnType<typeof writeBrief>>;
104
+ // When the image safety filter refuses the painting, Spark rewords the place and it is painted again;
105
+ // the wording is kept in corrections.json, so every later paint asks with it and no rebuild repeats the refusal.
106
+ // A wording Spark made in an earlier run counts its rewords, so the next one is a new ask.
107
+ const before = words?.tries ?? 0;
108
+ for (let refusals = 0; ; refusals++) {
109
+ const told = worded(place, words);
110
+ brief = withWords(await writeBrief(files.memo, world, told, layout), words);
111
+ files.write(files.place(place.key, 'brief.json'), brief);
112
+ try {
113
+ first = fromPhoto
114
+ ? await paintPhoto(files.memo, world, told, blockin, brief, take, concept, styleReference)
115
+ : await paintPlace(files.memo, world, told, layout, blockin, brief, take, drawingsIn(files, place), concept, styleReference);
116
+ break;
117
+ } catch (error) {
118
+ if (!refusedByFilter(error)) throw error;
119
+ if (refusals >= REWORDS) {
120
+ throw new Error(`the image safety filter refused to paint ${place.key} ${refusals + 1} times, also after ${refusals} reword(s) by Spark: `
121
+ + `reword its scene and looks with paint_words (calm, plain words), which paints it again (${error instanceof Error ? error.message.slice(0, 160) : error})`);
122
+ }
123
+ words = await reword(files.memo, told, layout, brief, before + refusals);
124
+ const fixes = readCorrections(files.place(place.key));
125
+ fixes.words = words;
126
+ writeCorrections(files.place(place.key), fixes);
127
+ console.log(` ${place.key}: the image safety filter refused the painting; Spark reworded it (kept in corrections.json), painting again: ${words.shows ?? place.shows}`);
128
+ }
129
+ }
130
+ inputs = hash(words);
131
+ files.write(files.place(place.key, 'painting-first.png'), first);
132
+ found = await critique(files.memo, worded(place, words), brief, first);
133
+ files.write(files.place(place.key, 'critique.json'), found);
134
+ }
135
+ // The person's repaints join the critic's. When the painting already has every earlier fix, only the new
136
+ // ones are painted over it, so the rest of the place stays exactly as the person saw it.
137
+ const asked = readCorrections(files.place(place.key)).repaint.map((r) => ({ key: 'person', problem: 'wrong' as const, region: r.region, fix: r.fix }));
138
+ const fixes = { issues: [...found.issues, ...asked] };
139
+ const id = (i: (typeof fixes.issues)[number]) => JSON.stringify([i.region, i.fix]);
140
+ // After a restyle the person's repaints are in the picture already; only the critic's new findings are painted.
141
+ const done = restyled ? asked.map(id) : reuse && fs.existsSync(files.place(place.key, 'painting.png')) ? record.applied ?? (have && !fs.existsSync(kept) ? found.issues.map(id) : null) : null;
142
+ const onTop = done && done.every((d) => fixes.issues.some((i) => id(i) === d));
143
+ const fresh = onTop ? fixes.issues.filter((i) => !done.includes(id(i))) : fixes.issues;
144
+ const from = onTop && !restyled ? fs.readFileSync(files.place(place.key, 'painting.png')) : first;
145
+ // A place built before the steps kept their painting (.ran/) gets it now, so this first repaint carries too.
146
+ const seeded = seedRan(files.place(place.key));
147
+ if (seeded.length) console.log(` ${place.key}: ${seeded.join(', ')} take the painting before this repaint as the one they ran on`);
148
+ let painting: Buffer;
149
+ try {
150
+ painting = await repaint(files.memo, place, from, { issues: fresh });
151
+ } catch (error) {
152
+ // The critic's own fixes are nice to have: a refused repaint leaves the painting as it is. The person's are not.
153
+ if (!refusedByFilter(error) || asked.length) throw error;
154
+ console.log(` ${place.key}: the image safety filter refused the critic's repaint; the painting is kept without it`);
155
+ painting = from;
156
+ }
157
+ // Inspect the FINAL candidate, including region fixes and restyles. Only a passing composition may
158
+ // become the input to masks/derive. Old paid art stays on disk until a replacement passes.
159
+ if (!fromPhoto) {
160
+ const report = files.place(place.key, 'painting-layout.json');
161
+ const previous = fs.existsSync(report) ? files.readJson(report) as { ok?: boolean; inputs?: string } : undefined;
162
+ if (!previous?.ok || previous.inputs !== paintingLayoutHash(place, layout, blockin, painting)) {
163
+ files.write(report, { ok: false, pending: true });
164
+ try {
165
+ const brief = files.readJson(files.place(place.key, 'brief.json')) as Awaited<ReturnType<typeof writeBrief>>;
166
+ const aligned = await alignPainting(files.memo, worded(place, words), layout, blockin, painting, brief, take, fixes.issues.map((i) => i.fix));
167
+ painting = aligned.painting;
168
+ files.write(report, { ok: true, inputs: paintingLayoutHash(place, layout, blockin, painting), attempts: aligned.attempts });
169
+ } catch (error) {
170
+ files.write(report, { ok: false, attempts: error instanceof PaintingLayoutError ? error.attempts : [], error: String(error) });
171
+ throw error;
172
+ }
173
+ }
174
+ }
175
+ files.write(files.place(place.key, 'painting.png'), painting);
176
+ files.write(kept, { inputs, applied: fixes.issues.map(id) });
177
+ markStyled(files, 'places', place.key, style);
178
+ console.log(`${place.key}: painted, ${fresh.length} fix(es)${onTop ? ' over the last painting' : ''}${fresh.map((i) => ` · ${i.key} ${i.problem}`).join('')}`);
179
+ }));
180
+ console.log(`calls sent: spark ${files.memo.sent.spark}, muse ${files.memo.sent.muse}`);
181
+ },
182
+ });