@gtanczyk/diorama 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) 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 +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -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 +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,135 @@
1
+ // Check composition before SAM/derive can turn a moved path into plausible but wrong geometry.
2
+ import sharp from 'sharp';
3
+ import { z } from 'zod';
4
+ import type { Memo } from '../meta/memo.ts';
5
+ import { inputHash } from '../meta/memo.ts';
6
+ import { museEdit } from '../meta/muse.ts';
7
+ import { askSparkJson } from '../meta/spark.ts';
8
+ import type { Place } from '../world/schema.ts';
9
+ import type { Brief } from './brief.ts';
10
+ import { FRAME, type Layout } from './layout.ts';
11
+
12
+ const Box = z.tuple([z.number().min(0).max(FRAME.width), z.number().min(0).max(FRAME.height), z.number().min(0).max(FRAME.width), z.number().min(0).max(FRAME.height)])
13
+ .refine(([l, t, r, b]) => r > l && b > t, 'box must have positive area');
14
+ export const PaintedLayout = z.object({
15
+ horizon: z.number().min(0).max(FRAME.height),
16
+ people: z.boolean(),
17
+ things: z.array(z.object({ key: z.string(), box: Box.nullable(), approach: z.boolean(), why: z.string() })),
18
+ floors: z.array(z.object({ key: z.string(), coverage: z.number().min(0).max(1), connected: z.boolean(), reachesEdges: z.boolean(), why: z.string() })),
19
+ });
20
+ export type PaintedLayout = z.infer<typeof PaintedLayout>;
21
+
22
+ export const paintingLayoutHash = (place: Place, layout: Layout, blockin: Buffer, painting: Buffer) =>
23
+ // Removing a decorative click target without painting it out is a supported masks-only edit.
24
+ // Its planned geometry and the pixels stay checked; changes to story targets still invalidate the report.
25
+ inputHash('paint-layout-v1', JSON.stringify({ key: place.key, shows: place.shows, mood: place.mood, targets: place.things.filter((t) => t.role !== 'prop') }), JSON.stringify(layout), blockin, painting);
26
+
27
+ /** Numeric plan, shared by painting, comparison and repair. Decorative silhouette details may change. */
28
+ export function layoutContract(place: Place, layout: Layout): string {
29
+ const boxes = layout.things.filter((b) => place.things.some((t) => t.key === b.key && t.role !== 'npc'))
30
+ .map((b) => `- ${b.key}: box [${Math.round(b.x - b.width / 2)}, ${Math.round(b.footY - b.height)}, ${Math.round(b.x + b.width / 2)}, ${Math.round(b.footY)}]`);
31
+ const floors = layout.floors.map((f) => `- ${f.key}: ${f.is}; polygon ${JSON.stringify(f.outline)}`);
32
+ return `Composition contract (${FRAME.width}x${FRAME.height} pixels, x right, y down):
33
+ Horizon y=${layout.horizon}. Keep this camera and perspective, not just the scene's subject.
34
+ Object boxes [left, top, right, bottom]:
35
+ ${boxes.join('\n')}
36
+ Walkable floor polygons:
37
+ ${floors.join('\n')}
38
+ Keep the connected walking surfaces, their joins and their frame-edge exits in these positions. A floor must remain solid usable ground, never water, a drop, a railing or an obstacle. Leave an approach on connected ground beside every item and exit. Do not replace a nearby interactive object with a distant decorative copy. Do not invent extra bridges to compensate for moving objects. The first image controls composition; other references control appearance only.`;
39
+ }
40
+
41
+ /**
42
+ * Spark's yes/no and its note disagree now and then: approach=false with "Continuous planks lead to it" rejected a
43
+ * pier six times over. A no counts when its note names what is in the way; a no whose note names nothing in the way
44
+ * is kept as drift (soft), and derive and verify measure the reach on the painting itself (SPOT_UNREACHABLE,
45
+ * FLOOR_UNREACHABLE) and send real breaks to repair.
46
+ */
47
+ const BLOCKED = /\b(no|not|never|without|cannot|can't|cant|isn't|doesn't|don't|nothing)\b|\bn't\b|water|sea\b|river|gap|drop|cliff|ledge|railing|fence|wall|barrier|block|obstruct|cut off|cut-off|sever|broken|break|missing|absent|unreach|inaccessib|isolat|separat|float|detach|disconnect|across|beyond|away from|moved|shifted|displaced|only (by|via|through)|too (steep|far|high|small|narrow)/i;
48
+ export const CONTRADICTED = "(Spark's note names nothing in the way)";
49
+ const saysBlocked = (why: string) => BLOCKED.test(why);
50
+
51
+ /** Fail closed on incomplete observations: an omitted floor is not evidence that it is connected. */
52
+ export function paintedLayoutIssues(place: Place, layout: Layout, seen: PaintedLayout): string[] {
53
+ const issues: string[] = [];
54
+ if (seen.people) issues.push('unwanted people painted into the background; the game adds its cast separately');
55
+ if (Math.abs(seen.horizon - layout.horizon) > 70) issues.push(`camera horizon moved from ${layout.horizon} to ${Math.round(seen.horizon)}`);
56
+ for (const t of place.things.filter((t) => t.role !== 'npc')) {
57
+ const planned = layout.things.find((b) => b.key === t.key);
58
+ if (!planned) { issues.push(`${t.key}: absent from plan`); continue; }
59
+ const matches = seen.things.filter((s) => s.key === t.key);
60
+ if (matches.length !== 1) { issues.push(`${t.key}: comparison must locate it exactly once`); continue; }
61
+ const s = matches[0];
62
+ if (!s.box) { issues.push(`${t.key}: not painted; ${s.why}`); continue; }
63
+ const [l, top, r, bottom] = s.box;
64
+ if (Math.abs((l + r) / 2 - planned.x) > Math.max(60, planned.width * 0.35)
65
+ || Math.abs(bottom - planned.footY) > Math.max(55, planned.height * 0.4)) issues.push(`${t.key}: moved from its planned box to [${s.box.join(', ')}]; ${s.why}`);
66
+ const w = (r - l) / planned.width, h = (bottom - top) / planned.height;
67
+ if (w < 0.45 || w > 2.2 || h < 0.45 || h > 2.2) issues.push(`${t.key}: painted size differs too much from its planned box; ${s.why}`);
68
+ if (t.role !== 'prop' && !s.approach) issues.push(`${t.key}: no usable approach on connected ground; ${s.why}${saysBlocked(s.why) ? '' : ` ${CONTRADICTED}`}`);
69
+ }
70
+ for (const f of layout.floors) {
71
+ const matches = seen.floors.filter((s) => s.key === f.key);
72
+ if (matches.length !== 1) { issues.push(`${f.key}: comparison must inspect this floor exactly once`); continue; }
73
+ const s = matches[0];
74
+ if (s.coverage < 0.6) issues.push(`${f.key}: only ${Math.round(s.coverage * 100)}% of its planned area is walkable ground; ${s.why}`);
75
+ if (!s.connected) issues.push(`${f.key}: its planned connection to the main floor is broken; ${s.why}${saysBlocked(s.why) ? '' : ` ${CONTRADICTED}`}`);
76
+ const edge = f.outline.some(([x, y]) => x <= 1 || x >= FRAME.width - 1 || y <= 1 || y >= FRAME.height - 1);
77
+ if (edge && !s.reachesEdges) issues.push(`${f.key}: the planned route no longer reaches its frame edge; ${s.why}${saysBlocked(s.why) ? '' : ` ${CONTRADICTED}`}`);
78
+ }
79
+ return issues;
80
+ }
81
+
82
+ export async function inspectPaintedLayout(memo: Memo, place: Place, layout: Layout, blockin: Buffer, painting: Buffer, brief: Brief): Promise<PaintedLayout> {
83
+ const prompt = `Compare image 1 (authoritative block-in plan) with image 2 (painted game background). Measure image 2, not image 1. Both are ${FRAME.width}x${FRAME.height}.
84
+ ${layoutContract(place, layout)}
85
+ Descriptions: ${JSON.stringify(place.things.filter((t) => t.role !== 'npc').map((t) => ({ key: t.key, role: t.role, looks: brief.looks[t.key] ?? t.looks })))}
86
+ Return every listed non-NPC thing and every planned floor exactly once, with these exact keys.
87
+ For each thing: its actual tight painted box or null if missing; approach=true only if the player can walk up to it along visibly continuous solid ground from the main floor (props may be decorative); why describes the evidence, including water, railings or gaps that block it. Do not use the nearest unrelated floor or a remote decorative copy as an approach.
88
+ For each floor: coverage is the fraction of its PLANNED polygon still covered by the intended walkable surface in image 2 (not overall floor area elsewhere). connected=true only if it visibly joins the first floor via solid walkable surfaces. reachesEdges=true if it still reaches every frame edge its planned polygon touched; true when no frame edges were planned. Small painterly edge differences are fine; displaced or severed routes are not.
89
+ Horizon is the camera's perspective horizon in image 2, not the skyline or top stair. people=true if image 2 contains any painted person or human figure: the game adds its cast separately, so a composition repair must not add people.
90
+ Return JSON {"horizon":300,"people":false,"things":[{"key":"...","box":[0,0,100,100],"approach":true,"why":"..."}],"floors":[{"key":"...","coverage":0.95,"connected":true,"reachesEdges":true,"why":"..."}]}`;
91
+ return PaintedLayout.parse(await askSparkJson(memo, { schema: PaintedLayout, label: `paint-layout:${place.key}`, prompt, images: [blockin, painting] }));
92
+ }
93
+
94
+ const REPAIRS = 1;
95
+ /**
96
+ * Drift the game copes with: masks, depth and walk are measured from the painting itself, so a camera a little
97
+ * higher, a thing a little off its box or a size off its block-in is no reason to repaint (Muse does it on almost
98
+ * every painting; failing on it left new worlds with no painting at all). What breaks play still repaints: a
99
+ * floor gone, cut off or not reaching its exit, a story thing missing or out of reach, people painted in.
100
+ */
101
+ export const softIssue = (issue: string) => /^camera horizon moved|: moved from its planned box|: painted size differs too much/.test(issue) || issue.endsWith(CONTRADICTED);
102
+
103
+ export type LayoutAttempt = { seen: PaintedLayout; issues: string[] };
104
+ export class PaintingLayoutError extends Error {
105
+ constructor(readonly attempts: LayoutAttempt[]) {
106
+ super(`PAINT_LAYOUT_MISMATCH: ${attempts.at(-1)!.issues.join('; ')}. A composition repair was tried. Read place_status.paintingLayout; use paint_retry for a new take or layout_fix for a simpler connected plan (after 2 rejected plans in a row, paint_retry comes first). No floor has been invented to hide this mismatch.`);
107
+ }
108
+ }
109
+
110
+ /** Full-frame repair: a moved camera/path cannot be fixed by pasting a little rectangle. Never publish a rejected candidate. */
111
+ export async function alignPainting(memo: Memo, place: Place, layout: Layout, blockin: Buffer, painting: Buffer, brief: Brief, take: number, fixes: string[] = []): Promise<{ painting: Buffer; attempts: LayoutAttempt[] }> {
112
+ const attempts: LayoutAttempt[] = [];
113
+ // One repair round: each is a full Muse repaint plus a Spark check (minutes per place); a second rarely saved
114
+ // a painting the first could not, and the place then goes to the agent (layout_fix, paint_retry) anyway.
115
+ for (let n = 0; n <= REPAIRS; n++) {
116
+ const seen = await inspectPaintedLayout(memo, place, layout, blockin, painting, brief);
117
+ const issues = paintedLayoutIssues(place, layout, seen);
118
+ attempts.push({ seen, issues });
119
+ if (!issues.some((i) => !softIssue(i))) {
120
+ if (issues.length) console.log(` ${place.key}: composition drift kept (the game measures the painting itself): ${issues.join('; ')}`);
121
+ return { painting, attempts };
122
+ }
123
+ if (n === REPAIRS) throw new PaintingLayoutError(attempts);
124
+ console.log(` ${place.key}: composition repair ${n + 1}/${REPAIRS}: ${issues.join('; ')}`);
125
+ const prompt = `Restore this game background to its planned composition. Image 1 is the authoritative spatial plan. Image 2 supplies ONLY the finished appearance, materials, palette and lighting. Repaint the whole composition where necessary, not a small patch. Preserve object identities and art style from image 2, but restore the camera, sizes, walking surfaces, joins and positions from image 1.
126
+ ${layoutContract(place, layout)}
127
+ Object appearances: ${JSON.stringify(brief.looks)}
128
+ Measured problems to correct:\n${issues.map((i) => `- ${i}`).join('\n')}
129
+ Retain these requested details where compatible with the plan:\n${fixes.map((f) => `- ${f}`).join('\n')}
130
+ No people, no text, labels, diagram lines or borders. Do not turn the plan's flat colours or tiles into the final art. Do not add unplanned boardwalks over water to hide a displaced object. Repair round ${n + 1}.`;
131
+ const candidate = await museEdit(memo, { label: `paint-align:${place.key}`, prompt, images: [blockin, painting], take });
132
+ painting = await sharp(candidate).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
133
+ }
134
+ throw new PaintingLayoutError(attempts);
135
+ }
@@ -0,0 +1,81 @@
1
+ import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
2
+ // paint: blockin.png becomes painting.png through one Muse edit.
3
+ import sharp from 'sharp';
4
+ import type { Memo } from '../meta/memo.ts';
5
+ import { museEdit } from '../meta/muse.ts';
6
+ import type { Place, World } from '../world/schema.ts';
7
+ import { fallsIn } from '../play/weather.ts';
8
+ import { paintedAnimals, type Brief } from './brief.ts';
9
+ import { shapeOf } from './blockin.ts';
10
+ import { FRAME, type Layout, type Shape } from './layout.ts';
11
+ import { layoutContract } from './paint-layout.ts';
12
+
13
+ /** Animals listed as masses are painted (a cow in its stall); any other animal is not. */
14
+ const noAnimals = (brief?: Brief) => {
15
+ const keep = paintedAnimals(brief);
16
+ return keep.length ? `no animals other than the ${keep.join(', ')} listed above (paint ${keep.length > 1 ? 'those' : 'that'} in, at rest)` : 'no animals';
17
+ };
18
+
19
+ /** The place's cast is drawn by the game: the place's words may put one in the scene ("a basket where a cat curls up"). */
20
+ const castNote = (place: Place) => {
21
+ const cast = place.things.filter((t) => t.role === 'npc').map((t) => t.key.replace(/-/g, ' '));
22
+ return cast.length ? ` The game draws ${cast.join(', ')} over the picture itself: do not paint ${cast.length > 1 ? 'them' : 'it'}, even where the place's words say ${cast.length > 1 ? 'they are' : 'it is'} there; leave that spot empty.` : '';
23
+ };
24
+
25
+ const NOUN: Record<Shape, string> = { block: 'box', low: 'low wide box', post: 'thin post', tree: 'tree shape', arch: 'arch', round: 'round shape', steps: 'steps', flat: 'flat oval on the floor' };
26
+
27
+ /** Where a mass is, in the words a painter uses: across (fifths) and how far back it stands. */
28
+ export function whereIs(layout: Layout, box: Layout['things'][number]): string {
29
+ const across = ['on the far left', 'left of centre', 'in the middle', 'right of centre', 'on the far right'][Math.min(4, Math.max(0, Math.floor((box.x / FRAME.width) * 5)))];
30
+ const back = (box.footY - layout.horizon) / (FRAME.height - layout.horizon);
31
+ return `${back < 0.3 ? 'at the back' : back < 0.65 ? 'halfway back' : 'at the front'}, ${across}`;
32
+ }
33
+
34
+ export function paintPrompt(world: World, place: Place, layout: Layout, brief?: Brief): string {
35
+ const look = (key: string, fallback: string) => brief?.looks[key] ?? fallback;
36
+ const masses = place.things
37
+ .filter((t) => t.role !== 'npc')
38
+ .map((t) => {
39
+ const box = layout.things.find((b) => b.key === t.key)!;
40
+ return `- the ${NOUN[shapeOf(box, t)]} ${whereIs(layout, box)}: ${look(t.key, t.looks)}`;
41
+ });
42
+ const floors = layout.floors.map((f) => `- the tiled floor area${layout.floors.length > 1 ? ` ${whereFloor(layout, f)}` : ''}: ${look(f.key, f.is)}, open floor to walk on`);
43
+ return `Paint this sketch as a finished background of a 2D point-and-click adventure game. The sketch is the plan: every shape stays where it is, at its size, and becomes what is listed; its shadow is where it stands on the floor. Keep the horizon and the camera of the sketch. The tiles on the floors only show the perspective: paint the real ground there, not tiles.
44
+
45
+ Place: ${place.shows}
46
+ Mood: ${place.mood}
47
+ Style: ${world.style}
48
+
49
+ The shapes:
50
+ ${[...masses, ...floors].join('\n')}
51
+
52
+ ${layoutContract(place, layout)}
53
+
54
+ Seen a little from above, like a stage. Keep the floor areas clear to walk on. No people, ${noAnimals(brief)}, no text or letters, no border.${castNote(place)}${fallsIn(`${place.shows} ${place.mood}`) ? '\nThe rain or snow itself is animated over the picture later: paint no falling streaks, drops or flakes in the air, only what the weather leaves (wet sheen, puddles, snow lying on the ground).' : ''}`;
55
+ }
56
+
57
+ function whereFloor(layout: Layout, f: Layout['floors'][number]): string {
58
+ const ys = f.outline.map(([, y]) => y), xs = f.outline.map(([x]) => x);
59
+ const mid = { key: f.key, colour: '', x: (Math.min(...xs) + Math.max(...xs)) / 2, footY: (Math.min(...ys) + Math.max(...ys)) / 2, width: 0, height: 0, metres: 1 };
60
+ return whereIs(layout, mid);
61
+ }
62
+
63
+ /** Children's drawings of the place or of things in it, as more images after the block-in. */
64
+ export type Drawing = { key: string; image: Buffer };
65
+
66
+ export function drawingsPrompt(place: Place, drawings: Drawing[]): string {
67
+ if (drawings.length === 0) return '';
68
+ const which = drawings.map((d, i) => `image ${i + 2} is ${d.key === place.key ? 'this place' : `the ${d.key}`}`);
69
+ return `
70
+
71
+ The other images are children's drawings on paper: ${which.join(', ')}. Paint what they drew so the children know it at once: their colours, shapes and ideas. A drawing fills its paper, but its thing does not fill the place: paint each at its real size beside the others (a hat is smaller than a log, a person comes up to a door's top). The composition still comes only from the first image; leave out the paper, the table and any written names.`;
72
+ }
73
+
74
+ /** One Muse edit, scaled back to the layout frame (Muse answers larger than asked). */
75
+ export async function paintPlace(memo: Memo, world: World, place: Place, layout: Layout, blockin: Buffer, brief?: Brief, take = 0, drawings: Drawing[] = [], concept?: Buffer, styleReference?: Buffer): Promise<Buffer> {
76
+ const prompt = paintPrompt(world, place, layout, brief) + drawingsPrompt(place, drawings)
77
+ + (styleReference ? `\nImage ${drawings.length + 2}: ${STYLE_REFERENCE_PROMPT}` : '')
78
+ + (concept ? '\nThe LAST image is the creator-approved concept for this place. Preserve its distinctive architecture, palette, materials and visual identity. The FIRST image remains the authoritative layout: adapt the approved appearance to those positions and clear walking areas. Do not add characters or extra objects from the concept.' : '');
79
+ const painted = await museEdit(memo, { label: `paint:${place.key}`, prompt, images: [blockin, ...drawings.map((d) => d.image), ...(styleReference ? [styleReference] : []), ...(concept ? [concept] : [])], take });
80
+ return sharp(painted).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
81
+ }
@@ -0,0 +1,123 @@
1
+ import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
2
+ // A real photo as a place: crop it to the game frame, let Spark read the room into things,
3
+ // and let Muse repaint it in the world's style without moving anything.
4
+ import sharp from 'sharp';
5
+ import { z } from 'zod';
6
+ import type { Memo } from '../meta/memo.ts';
7
+ import { museEdit } from '../meta/muse.ts';
8
+ import { askSparkJson } from '../meta/spark.ts';
9
+ import { Place, World } from '../world/schema.ts';
10
+ import type { Brief } from './brief.ts';
11
+ import { FRAME } from './layout.ts';
12
+
13
+ /** Where the game frame sits in the original photo, so a film can line the real and painted frames up. */
14
+ export const PhotoCrop = z.object({
15
+ source: z.object({ width: z.number(), height: z.number() }),
16
+ crop: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }),
17
+ frame: z.object({ width: z.number(), height: z.number() }),
18
+ });
19
+ export type PhotoCrop = z.infer<typeof PhotoCrop>;
20
+
21
+ /** The largest centred crop with the frame's aspect, scaled to the frame. EXIF rotation is applied first. */
22
+ export async function framePhoto(photo: Buffer): Promise<{ image: Buffer; crop: PhotoCrop }> {
23
+ const upright = await sharp(photo).rotate().toBuffer();
24
+ const { width = 0, height = 0 } = await sharp(upright).metadata();
25
+ const aspect = FRAME.width / FRAME.height;
26
+ const w = Math.min(width, Math.round(height * aspect));
27
+ const h = Math.min(height, Math.round(w / aspect));
28
+ const crop = { x: Math.round((width - w) / 2), y: Math.round((height - h) / 2), width: w, height: h };
29
+ const image = await sharp(upright).extract({ left: crop.x, top: crop.y, width: w, height: h })
30
+ .resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
31
+ return { image, crop: { source: { width, height }, crop, frame: { ...FRAME } } };
32
+ }
33
+
34
+ /** Spark reads the photo into a place: what it shows and the things in it, the way out as an exit. */
35
+ export async function describePhoto(memo: Memo, image: Buffer, idea: string): Promise<Place> {
36
+ const prompt = `This photo of a real room becomes the first place of a 2D point-and-click adventure. The game idea:
37
+ """
38
+ ${idea}
39
+ """
40
+
41
+ Describe the room as it is; invent nothing that is not in the photo, and leave out any person in it.
42
+ - key: a kebab-case name for the room
43
+ - shows: what the photo shows, front to back
44
+ - mood: the light and time of day in the photo, and the feeling it should have in the game
45
+ - things: 3 to 7 things big enough to click, each with role prop, item or exit; the door is an exit, one small loose object the hero could pick up is an item
46
+
47
+ Reply with JSON only:
48
+ {"key": "...", "shows": "...", "mood": "...", "things": [{"key": "kebab-key", "role": "prop | item | exit", "looks": "a few words"}]}`;
49
+ let ask = prompt;
50
+ for (let attempt = 1; attempt <= 3; attempt++) {
51
+ const answer = await askSparkJson(memo, { label: `photo-place#${attempt}`, prompt: ask, images: [image], personal: true });
52
+ const parsed = Place.omit({ exits: true }).safeParse(answer);
53
+ const issues = parsed.success ? (parsed.data.things.some((t) => t.role === 'exit') ? [] : ['no thing has role exit; the door is the exit']) : parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
54
+ if (parsed.success && issues.length === 0) return { ...parsed.data, exits: [] };
55
+ console.log(` photo place try ${attempt}: ${issues.join('; ')}`);
56
+ ask = `${prompt}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nFix: ${issues.join('; ')}`;
57
+ }
58
+ throw new Error('Spark could not read the photo into a place in 3 tries');
59
+ }
60
+
61
+ export function photoPaintPrompt(world: World, place: Place, brief?: Brief): string {
62
+ const looks = place.things.filter((t) => t.role !== 'npc').map((t) => `- ${t.key}: ${brief?.looks[t.key] ?? t.looks}`);
63
+ return `Repaint this photo as a finished background of a 2D point-and-click adventure game. Keep the camera and every object exactly where and as big as it is in the photo: same walls, same furniture, same door, same floor. Only the medium changes.
64
+
65
+ Style: ${world.style}
66
+ Mood: ${place.mood}
67
+ Objects to keep recognisable:
68
+ ${looks.join('\n')}
69
+
70
+ Remove every person; paint the furniture and floor behind them. Keep the floor clear to walk on. No text or letters, no border.`;
71
+ }
72
+
73
+ export async function paintPhoto(memo: Memo, world: World, place: Place, photo: Buffer, brief?: Brief, take = 0, concept?: Buffer, styleReference?: Buffer): Promise<Buffer> {
74
+ const prompt = photoPaintPrompt(world, place, brief) + (styleReference ? '\nThe LAST image: ' + STYLE_REFERENCE_PROMPT : '') + (concept ? '\nThe second image is the creator-approved visual concept: preserve its palette and treatment. The first photo is authoritative for the real room, furniture, perspective and layout.' : '');
75
+ const painted = await museEdit(memo, { label: `paint:${place.key}`, prompt, images: [photo, ...(concept ? [concept] : []), ...(styleReference ? [styleReference] : [])], take });
76
+ return sharp(painted).resize(FRAME.width, FRAME.height, { fit: 'fill' }).png().toBuffer();
77
+ }
78
+
79
+ // Looks feed every later painting (cast, the four model views, sprites), so they describe the person and not
80
+ // the photo: "lying against a headboard" in looks puts a headboard behind the figure in every view, and
81
+ // "legs not visible" leaves the painters to guess. This catches what the photo leaked into them.
82
+ const POSE_WORDS = /\b(not visible|out of (the )?frame|cropped|cut off|(from|at) the (chest|waist|shoulders)|chest[- ]up|waist[- ]up|lying|lies|reclin\w*|sitting|sits|seated|leaning|propped|headboard|pillows?|bed|sofa|couch|armchair|chair|desk|blanket|duvet|selfie|in the photo|camera)\b/gi;
83
+
84
+ /** The words in looks that are about the photo (pose, furniture, framing) rather than the person. */
85
+ export function poseWords(looks: string): string[] {
86
+ return [...new Set([...looks.matchAll(POSE_WORDS)].map((m) => m[0].toLowerCase()))];
87
+ }
88
+
89
+ /** Spark describes the person in a photo head to toe, so world.json's hero is them, standing, whatever the photo shows. */
90
+ export async function describePerson(memo: Memo, photo: Buffer): Promise<{ key: string; looks: string }> {
91
+ const prompt = `This photo shows the real person who will be the hero of a 2D point-and-click adventure, where they will stand and walk. Describe them head to toe as a game character standing on their feet: build, hair, face, beard or glasses, every piece of clothing with its colour, trousers or skirt, shoes. No name, no guesses about who they are.
92
+
93
+ Describe the person only, never the photo: not how they sit or lie, not the bed, chair or pillow, not how the photo frames them. When the photo does not show part of them (legs, trousers, shoes), choose plain ones that go with the rest and describe those as if seen; never write that something is not visible.
94
+
95
+ The key names who they are in the game (like the-developer), not where they are in the photo.
96
+
97
+ Reply with JSON only: {"key": "kebab-key like the-developer", "looks": "..."}`;
98
+ let ask = prompt;
99
+ let hero: { key: string; looks: string } | undefined;
100
+ for (let attempt = 1; attempt <= 3; attempt++) {
101
+ const answer = await askSparkJson<{ key: string; looks: string }>(memo, { label: `hero-photo#${attempt}`, prompt: ask, images: [photo], personal: true });
102
+ hero = World.shape.hero.parse(answer);
103
+ const leaked = poseWords(`${hero.key.replace(/-/g, ' ')} ${hero.looks}`);
104
+ if (!leaked.length) return hero;
105
+ console.log(` hero photo try ${attempt}: looks speak of the photo (${leaked.join(', ')})`);
106
+ ask = `${prompt}\n\nYour last answer was:\n${JSON.stringify(answer)}\n\nFix: it describes the photo, not the person (${leaked.join(', ')}). Describe them standing, head to toe, with every piece of clothing as if seen.`;
107
+ }
108
+ return hero!;
109
+ }
110
+
111
+ /** Whether the photo shows the person standing with the whole body in frame, so the hero can be painted in that very pose. */
112
+ export async function photoStanding(memo: Memo, photo: Buffer): Promise<boolean> {
113
+ const answer = await askSparkJson<{ standing?: unknown }>(memo, {
114
+ label: 'hero-photo-pose', images: [photo], personal: true,
115
+ prompt: 'Does this photo show the person standing upright on their feet, with the whole body from the top of the head down to both feet inside the frame? Sitting, lying, leaning back, kneeling, or any part of the legs or feet cut off by the frame or hidden is no.\n\nReply with JSON only: {"standing": true or false}',
116
+ });
117
+ return answer.standing === true;
118
+ }
119
+
120
+ /** Upright, at most 1600 px on the long side, PNG: what Spark and Muse get. */
121
+ export function normalisePhoto(photo: Buffer): Promise<Buffer> {
122
+ return sharp(photo).rotate().resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }).png().toBuffer();
123
+ }
@@ -0,0 +1,256 @@
1
+ // plates: for things the hero carries off, the spot where the thing was, painted empty. Muse edits a close crop
2
+ // around the thing; only the thing's mask (grown a little, feathered, matched in tone) is taken from it.
3
+ // SAM then looks for the thing again on the plate: if it is still there, the plate is flagged.
4
+ import sharp from 'sharp';
5
+ import { emptyMask, maskArea, maskToPng, type Mask } from '../image/mask.ts';
6
+ import { clampRect, components, dilate, growRect, intersect, maskBox, maskIou, rectMask, subtract, union, unionAll } from '../image/raster.ts';
7
+ import type { Memo } from '../meta/memo.ts';
8
+ import { museEdit } from '../meta/muse.ts';
9
+ import { samFind } from '../meta/sam.ts';
10
+ import type { Place } from '../world/schema.ts';
11
+ import { FRAME } from './layout.ts';
12
+
13
+ export const PLATE_GROW = 12;
14
+ /** Mean difference (0-255) from the painting just around a patch above which the seam shows. */
15
+ export const SEAM_SHOWS = 25;
16
+
17
+ /** Pastes `patch` over `painting` inside `mask`, with a soft edge. */
18
+ export async function pasteInside(painting: Buffer, patch: Buffer, mask: Mask, feather = 4): Promise<Buffer> {
19
+ const { width: W, height: H } = FRAME;
20
+ const alpha = await sharp(await maskToPng(mask)).blur(feather).toColourspace('b-w').raw().toBuffer();
21
+ return pasteAlpha(painting, patch, alpha);
22
+ }
23
+
24
+ /**
25
+ * `patch` over `painting` through `alpha` (raw, one byte per pixel, FRAME size); everything at alpha 0 is left as it
26
+ * was. The RGBA patch is built by hand: sharp's joinChannel with a raw alpha let the whole patch through, so every
27
+ * edit used to replace the whole painting.
28
+ */
29
+ export async function pasteAlpha(painting: Buffer, patch: Buffer, alpha: Buffer): Promise<Buffer> {
30
+ const { width: W, height: H } = FRAME;
31
+ const rgb = await sharp(patch).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer();
32
+ const rgba = Buffer.alloc(W * H * 4);
33
+ for (let i = 0; i < W * H; i++) {
34
+ rgba[i * 4] = rgb[i * 3];
35
+ rgba[i * 4 + 1] = rgb[i * 3 + 1];
36
+ rgba[i * 4 + 2] = rgb[i * 3 + 2];
37
+ rgba[i * 4 + 3] = alpha[i];
38
+ }
39
+ const cut = await sharp(rgba, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer();
40
+ return sharp(painting).resize(W, H, { fit: 'fill' }).composite([{ input: cut }]).png().toBuffer();
41
+ }
42
+
43
+ /**
44
+ * Muse's edit inside `area` only, transparent elsewhere, with a soft edge. A Muse edit repaints the whole picture and
45
+ * often shifts its tone (darker, wetter, another light), so the patch is first matched, channel by channel, to the
46
+ * painting in a ring just outside the area: mean and spread of the edit's ring are mapped onto the painting's.
47
+ */
48
+ export async function patchInside(painting: Buffer, edited: Buffer, area: Mask, feather = 4): Promise<{ png: Buffer; ringError: number; stain: number }> {
49
+ const { width: W, height: H } = FRAME;
50
+ const [paint, edit] = await Promise.all([painting, edited].map((b) => sharp(b).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer()));
51
+ const ring = subtract(dilate(area, 18), area);
52
+ const stats = (px: Buffer) => [0, 1, 2].map((c) => {
53
+ let n = 0, sum = 0, sq = 0;
54
+ for (let i = 0; i < W * H; i++) if (ring.bits[i]) (n++, sum += px[i * 3 + c], sq += px[i * 3 + c] ** 2);
55
+ const mean = n ? sum / n : 0;
56
+ return { mean, sd: n ? Math.sqrt(Math.max(1, sq / n - mean * mean)) : 1 };
57
+ });
58
+ const [want, have] = [stats(paint), stats(edit)];
59
+ // How far the matched edit still is from the painting just around the patch: a Muse edit that moved things
60
+ // (it repaints the whole picture) leaves a seam however good its fill looks, and scores high here.
61
+ let err = 0, n = 0;
62
+ for (let i = 0; i < W * H; i++) {
63
+ if (!ring.bits[i]) continue;
64
+ for (let c = 0; c < 3; c++) {
65
+ const k = Math.min(1.6, Math.max(0.6, want[c].sd / have[c].sd));
66
+ err += Math.abs(want[c].mean + (edit[i * 3 + c] - have[c].mean) * k - paint[i * 3 + c]);
67
+ }
68
+ n += 3;
69
+ }
70
+ const alpha = await sharp(await maskToPng(area)).blur(feather).extractChannel(0).raw().toBuffer();
71
+ const out = Buffer.alloc(W * H * 4);
72
+ for (let i = 0; i < W * H; i++) {
73
+ if (!alpha[i]) continue;
74
+ for (let c = 0; c < 3; c++) {
75
+ // The spread is matched only part way, so a flat ring cannot flatten the fill to one colour.
76
+ const k = Math.min(1.6, Math.max(0.6, want[c].sd / have[c].sd));
77
+ out[i * 4 + c] = Math.max(0, Math.min(255, Math.round(want[c].mean + (edit[i * 3 + c] - have[c].mean) * k)));
78
+ }
79
+ out[i * 4 + 3] = alpha[i];
80
+ }
81
+ // A stain: the share of the patch darker or lighter than nearly all of the ground around it (a hole, a puddle,
82
+ // a smear Muse painted where the thing was).
83
+ const luma = (px: Buffer, i: number, step: number) => 0.3 * px[i * step] + 0.59 * px[i * step + 1] + 0.11 * px[i * step + 2];
84
+ const around: number[] = [];
85
+ for (let i = 0; i < W * H; i++) if (ring.bits[i]) around.push(luma(paint, i, 3));
86
+ around.sort((a, b) => a - b);
87
+ const [lo, hi] = around.length ? [around[Math.floor(around.length * 0.02)] - 20, around[Math.floor(around.length * 0.98)] + 20] : [0, 255];
88
+ let inside = 0, off = 0;
89
+ for (let i = 0; i < W * H; i++) {
90
+ if (!area.bits[i]) continue;
91
+ inside++;
92
+ const l = luma(out, i, 4);
93
+ if (l < lo || l > hi) off++;
94
+ }
95
+ return { png: await sharp(out, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer(), ringError: n ? err / n : 0, stain: inside ? off / inside : 0 };
96
+ }
97
+
98
+ /**
99
+ * A Muse edit of the part of the painting around a small thing, blown up to the full frame, then put back at its place
100
+ * in a copy of the painting; null for a thing too large for a close view. Seen whole, a small thing is a few pixels
101
+ * Muse may fill with whatever it likes; seen close it may redraw the ground with other lines. Neither wins always.
102
+ */
103
+ async function editAround(memo: Memo, label: string, prompt: string, painting: Buffer, mask: Mask, take = 0): Promise<Buffer | null> {
104
+ const { width: W, height: H } = FRAME;
105
+ const box = maskBox(mask);
106
+ if (!box) return null;
107
+ // Three times the thing each way, in the frame's shape, at least a quarter of the frame wide.
108
+ const w = Math.min(W, Math.max(W / 4, (box[2] - box[0]) * 3, ((box[3] - box[1]) * 3 * W) / H));
109
+ if (w > W * 0.6) return null;
110
+ const h = (w * H) / W;
111
+ const left = Math.round(Math.max(0, Math.min(W - w, (box[0] + box[2]) / 2 - w / 2)));
112
+ const top = Math.round(Math.max(0, Math.min(H - h, (box[1] + box[3]) / 2 - h / 2)));
113
+ const crop = { left, top, width: Math.min(W - left, Math.round(w)), height: Math.min(H - top, Math.round(h)) };
114
+ const full = await sharp(painting).resize(W, H, { fit: 'fill' }).png().toBuffer();
115
+ const close = await sharp(full).extract(crop).resize(W, H, { kernel: 'lanczos3' }).png().toBuffer();
116
+ const edited = await museEdit(memo, { label: `${label}:close`, prompt, images: [close], take });
117
+ const back = await sharp(edited).resize(crop.width, crop.height, { fit: 'fill' }).png().toBuffer();
118
+ return sharp(full).composite([{ input: back, left: crop.left, top: crop.top }]).png().toBuffer();
119
+ }
120
+
121
+ /** How far from the thing's mask a part SAM left out (a rope, a handle, a strap) may reach. */
122
+ export const ATTACHED_REACH = 72;
123
+
124
+ /**
125
+ * SAM's mask often leaves out a part of the thing (the rope tied to a bucket), which then lies on the floor once the
126
+ * thing is gone. Muse, asked to remove the thing, removes those parts too: where the edit differs strongly from the
127
+ * painting just outside the area, in blobs touching it, the area grows to take them in. A shifted or retoned edit
128
+ * changes everything a little, and Muse adds things of its own; only clear changes towards the ground count, and at most as much again as the thing itself.
129
+ */
130
+ export async function withAttached(painting: Buffer, edited: Buffer, mask: Mask, area: Mask): Promise<Mask> {
131
+ const { width: W, height: H } = FRAME;
132
+ const [paint, edit] = await Promise.all([painting, edited].map((b) => sharp(b).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer()));
133
+ const near = subtract(dilate(mask, ATTACHED_REACH), area);
134
+ // The edit's overall shift in tone, read on the ring around the area, is taken out first.
135
+ const ring = subtract(dilate(area, 10), area);
136
+ const shift = [0, 1, 2].map((c) => {
137
+ const d: number[] = [];
138
+ for (let i = 0; i < W * H; i++) if (ring.bits[i]) d.push(edit[i * 3 + c] - paint[i * 3 + c]);
139
+ return d.length ? d.sort((a, b) => a - b)[d.length >> 1] : 0;
140
+ });
141
+ // The ground, as the painting has it around the area.
142
+ const ground = [0, 1, 2].map((c) => {
143
+ const v: number[] = [];
144
+ for (let i = 0; i < W * H; i++) if (ring.bits[i]) v.push(paint[i * 3 + c]);
145
+ return v.length ? v.sort((a, b) => a - b)[v.length >> 1] : 0;
146
+ });
147
+ const fromGround = (px: Buffer, i: number, off = [0, 0, 0]) => Math.hypot(...[0, 1, 2].map((c) => px[i * 3 + c] - off[c] - ground[c]));
148
+ const changed = emptyMask(W, H);
149
+ for (let i = 0; i < W * H; i++) {
150
+ if (!near.bits[i]) continue;
151
+ const d = Math.hypot(...[0, 1, 2].map((c) => edit[i * 3 + c] - shift[c] - paint[i * 3 + c]));
152
+ // Removed means the edit turned it into ground: something Muse added (a stain, a shadow) is not part of the thing.
153
+ if (d > 60 && fromGround(edit, i, shift) < fromGround(paint, i) - 40) changed.bits[i] = 1;
154
+ }
155
+ const touching = dilate(area, 2);
156
+ let budget = maskArea(mask);
157
+ const parts: Mask[] = [];
158
+ for (const blob of components(dilate(changed, 1))) {
159
+ const size = maskArea(blob);
160
+ if (size < 40 || size > budget || maskArea(intersect(blob, touching)) === 0) continue;
161
+ parts.push(blob);
162
+ budget -= size;
163
+ }
164
+ return parts.length ? union(area, dilate(unionAll(W, H, parts), 4)) : area;
165
+ }
166
+
167
+ /** How bad a patch looks, in seam units: its seam, and a stain over a tenth of it as bad as a seam that shows. */
168
+ const STAIN_WEIGHT = SEAM_SHOWS * 10;
169
+ const flaw = (p: { ringError: number; stain: number }) => p.ringError + STAIN_WEIGHT * p.stain;
170
+
171
+ /** ringError: mean difference (0-255) from the painting just around the patch (see SEAM_SHOWS). */
172
+ export type Plate = { thing: string; png: Buffer; stillThere: boolean; iou: number; ringError: number; stain: number };
173
+
174
+ export async function plateFor(memo: Memo, place: Place, thing: { key: string; phrase: string; mask: Mask }, painting: Buffer, ground: string): Promise<Plate> {
175
+ const prompt = `Remove the ${thing.phrase} from this picture. Where it was, paint only ${ground}, continuing the surroundings exactly: the same flat colours, texture, ink lines and light as right next to it. Add nothing new: no objects, stones, debris, stains, puddles or shadows. Leave everything else exactly as it is. No people, no text.`;
176
+ const label = `plate:${place.key}/${thing.key}`;
177
+ const area = dilate(thing.mask, PLATE_GROW);
178
+ // Two edits, the whole painting and a close view; the one that meets the painting around the patch best is kept.
179
+ const takes = async (take: number) => {
180
+ const edits = (await Promise.all([museEdit(memo, { label, prompt, images: [painting], take }), editAround(memo, label, prompt, painting, thing.mask, take)]))
181
+ .filter((e): e is Buffer => e !== null);
182
+ // The plate is only the patch where the thing was: drawn over the painting (or the backdrop), nothing else can differ.
183
+ return Promise.all(edits.map(async (e) => patchInside(painting, e, await withAttached(painting, e, thing.mask, area))));
184
+ };
185
+ let patches = await takes(0);
186
+ // A seam or a stain that shows: one more take of each, and the best of all four.
187
+ if (Math.min(...patches.map(flaw)) > SEAM_SHOWS) patches = [...patches, ...(await takes(1))];
188
+ const { png, ringError, stain } = patches.sort((a, b) => flaw(a) - flaw(b))[0];
189
+ // Is it still there? SAM by the same phrase on the painting with the patch on, inside the area the plate replaced.
190
+ const patched = await sharp(painting).resize(FRAME.width, FRAME.height, { fit: 'fill' }).composite([{ input: png }]).png().toBuffer();
191
+ const found = await samFind(memo, { label: `plate-check:${place.key}/${thing.key}`, image: patched, prompt: thing.phrase });
192
+ const iou = Math.max(0, ...found.map((f) => maskIou(intersect(f.mask, area), thing.mask)));
193
+ const left = found.reduce((px, f) => px + maskArea(intersect(f.mask, thing.mask)), 0);
194
+ return { thing: thing.key, png, iou, ringError, stain, stillThere: iou > 0.4 || left > 0.4 * maskArea(thing.mask) };
195
+ }
196
+
197
+ /** How far around a takeable thing a change keeps off: Muse shifts what it redraws by a few dozen px. */
198
+ export const CHANGE_KEEP_OFF = 24;
199
+
200
+ /**
201
+ * Where a change plate shows: the thing grown wide (light spills), soft-edged, but never over the `others`
202
+ * (things the hero can take, grown by CHANGE_KEEP_OFF) outside the changed thing itself. They keep the
203
+ * painting's pixels, so their hover outline and their plate still match what is seen.
204
+ */
205
+ export async function changeAlpha(mask: Mask, others: Mask[]): Promise<Buffer> {
206
+ const alpha = await sharp(await maskToPng(dilate(mask, 48))).blur(14).extractChannel(0).raw().toBuffer();
207
+ if (!others.length) return alpha;
208
+ let keep: Mask | undefined;
209
+ for (const o of others) keep = keep ? union(keep, o) : o;
210
+ const off = await sharp(await maskToPng(dilate(keep!, CHANGE_KEEP_OFF))).blur(6).extractChannel(0).raw().toBuffer();
211
+ for (let i = 0; i < alpha.length; i++) if (!mask.bits[i]) alpha[i] = Math.round((alpha[i] * (255 - off[i])) / 255);
212
+ return alpha;
213
+ }
214
+
215
+ /**
216
+ * The changed thing's own shape in its change plate: Muse redraws all it sees, and a boat that gets a lantern may come
217
+ * back longer or lower than the one painted, with the lantern standing up out of it. SAM by how it looks now
218
+ * (`looks`: what was found near the painted thing, all of it), else by its box grown for that, on the plate; undefined
219
+ * when what it finds is not the thing (little of the painted one, or far bigger). The runtime outlines this while
220
+ * the change holds, so the hover line follows the thing as it is drawn then.
221
+ */
222
+ export async function changeShape(memo: Memo, place: Place, thing: { key: string; mask: Mask }, plate: Buffer, looks: string): Promise<Mask | undefined> {
223
+ const { width: W, height: H } = FRAME;
224
+ const box = maskBox(thing.mask);
225
+ if (!box) return undefined;
226
+ const near = clampRect(growRect(box, 1.6), W, H);
227
+ const rgba = await sharp(plate).ensureAlpha().raw().toBuffer();
228
+ const flat = await sharp(rgba, { raw: { width: W, height: H, channels: 4 } }).removeAlpha().png().toBuffer();
229
+ const px = maskArea(thing.mask);
230
+ // Only where the plate shows: elsewhere the painting is drawn, not the change.
231
+ const shown = (m: Mask): Mask => ({ ...m, bits: m.bits.map((b, i) => (b && rgba[i * 4 + 3] > 96 ? 1 : 0)) });
232
+ const fits = (m: Mask) => maskArea(intersect(m, thing.mask)) >= 0.6 * px && maskArea(m) <= 3 * px;
233
+ const nearBox = rectMask(W, H, near);
234
+ const named = await samFind(memo, { label: `change-shape:${place.key}/${thing.key}:${looks}`, image: flat, prompt: looks });
235
+ const parts = named.filter((f) => maskArea(intersect(f.mask, nearBox)) >= 0.5 * maskArea(f.mask)).map((f) => f.mask);
236
+ if (parts.length) {
237
+ const shape = shown(unionAll(W, H, parts));
238
+ if (fits(shape)) return shape;
239
+ }
240
+ const [left, top, right, bottom] = near;
241
+ const [boxed] = await samFind(memo, { label: `change-shape-box:${place.key}/${thing.key}`, image: flat, prompt: [{ left, top, right, bottom }] });
242
+ const shape = boxed && shown(boxed.mask);
243
+ return shape && fits(shape) ? shape : undefined;
244
+ }
245
+
246
+ /** For a thing the story changes (a beacon lit): the thing as it looks after, transparent elsewhere, so several
247
+ * changes in one place stack. Light may spill, so the area is wide. */
248
+ export async function changePlate(memo: Memo, place: Place, thing: { key: string; phrase: string; mask: Mask }, painting: Buffer, looks: string, others: Mask[] = []): Promise<Buffer> {
249
+ const prompt = `Change only the ${thing.phrase} in this painting: it is now ${looks}. Add the light it casts on what is right around it. Leave everything else exactly as it is and where it is, same style. No people, no animals, no text.`;
250
+ const edited = await museEdit(memo, { label: `change:${place.key}/${thing.key}:${looks}`, prompt, images: [painting] });
251
+ const { width: W, height: H } = FRAME;
252
+ const alpha = await changeAlpha(thing.mask, others);
253
+ const rgba = await sharp(edited).resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer();
254
+ for (let i = 0; i < W * H; i++) rgba[i * 4 + 3] = alpha[i];
255
+ return sharp(rgba, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer();
256
+ }