@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,326 @@
1
+ // backdrop: the painting with every card (each sharp standing thing) taken out, for parallax. When the
2
+ // cards slide against the floor, a few px of what is behind them show; Muse paints what is there.
3
+ // Muse repaints the whole picture on an edit, so only the cards' masks (grown, feathered) are taken from it.
4
+ // A card SAM still finds on the backdrop gets a smeared fill of its surroundings instead: blurry beats a ghost.
5
+ // Muse sometimes leaves a card's dark silhouette behind (a ghost SAM does not call by the card's name): that
6
+ // is read from the pixels, the outline darker inside than out, and asked of Muse once more, then smeared.
7
+ // Muse's repaint is brought to the painting's tone around each card before it is pasted, so it leaves no patch.
8
+ import sharp from 'sharp';
9
+ import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
10
+ import { components, dilate, intersect, maskBox, maskIou, subtract, unionAll } from '../image/raster.ts';
11
+ import type { Memo } from '../meta/memo.ts';
12
+ import { museEdit } from '../meta/muse.ts';
13
+ import { samFind } from '../meta/sam.ts';
14
+ import type { Place } from '../world/schema.ts';
15
+ import { FRAME } from './layout.ts';
16
+ import { pasteInside } from './plates.ts';
17
+
18
+ /** Far more than a card ever slides against the backdrop behind it (see PARALLAX_PX). */
19
+ export const BACKDROP_GROW = 14;
20
+
21
+ /**
22
+ * Fills the masked pixels from their surroundings: a pyramid of averages of the known pixels,
23
+ * read back from the finest level that has any. Smooth, never sharp; fine for a strip of a few px.
24
+ * With `known`, only those pixels feed the fill.
25
+ */
26
+ export function smearFill(rgb: Uint8Array, W: number, H: number, hole: Mask, known?: Mask): Uint8Array {
27
+ type Level = { w: number; h: number; sum: Float32Array; weight: Float32Array };
28
+ const base: Level = { w: W, h: H, sum: new Float32Array(W * H * 3), weight: new Float32Array(W * H) };
29
+ for (let i = 0; i < W * H; i++) {
30
+ // Known pixels: everything outside the hole, or only `known` (to grow a layer from its own pixels).
31
+ if (known ? !known.bits[i] : hole.bits[i]) continue;
32
+ base.weight[i] = 1;
33
+ for (let c = 0; c < 3; c++) base.sum[i * 3 + c] = rgb[i * 3 + c];
34
+ }
35
+ const levels = [base];
36
+ while (levels.at(-1)!.w > 1 || levels.at(-1)!.h > 1) {
37
+ const f = levels.at(-1)!;
38
+ const w = Math.max(1, Math.ceil(f.w / 2));
39
+ const h = Math.max(1, Math.ceil(f.h / 2));
40
+ const next: Level = { w, h, sum: new Float32Array(w * h * 3), weight: new Float32Array(w * h) };
41
+ for (let y = 0; y < f.h; y++) for (let x = 0; x < f.w; x++) {
42
+ const i = y * f.w + x;
43
+ const j = (y >> 1) * w + (x >> 1);
44
+ next.weight[j] += f.weight[i];
45
+ for (let c = 0; c < 3; c++) next.sum[j * 3 + c] += f.sum[i * 3 + c];
46
+ }
47
+ levels.push(next);
48
+ }
49
+ const out = rgb.slice();
50
+ for (let i = 0; i < W * H; i++) {
51
+ if (!hole.bits[i]) continue;
52
+ let [x, y] = [i % W, Math.floor(i / W)];
53
+ for (const l of levels) {
54
+ const j = y * l.w + x;
55
+ if (l.weight[j] > 0) {
56
+ for (let c = 0; c < 3; c++) out[i * 3 + c] = l.sum[j * 3 + c] / l.weight[j];
57
+ break;
58
+ }
59
+ [x, y] = [x >> 1, y >> 1];
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export type Card = { key: string; phrase: string; mask: Mask };
66
+ /** cut: the card leaves a painted part of its thing on the backdrop (see seamOf): the longest run, where. */
67
+ export type Backdrop = { png: Buffer; report: Record<string, { stillThere: boolean; iou: number; ghost?: boolean; /** Taken out on a close crop after the whole-picture edit left it. */ close?: boolean; cut?: { px: number; box: [number, number, number, number] | null } }> };
68
+
69
+ /** Darker than this (in luma, 0..255) across its outline, a removed card left its silhouette. */
70
+ const GHOST_STEP = 12;
71
+ /** ...and this share of its inside is darker than what Muse painted around it. */
72
+ const GHOST_SHARE = 0.45;
73
+
74
+ const luma = (rgb: Uint8Array, i: number) => 0.3 * rgb[i * 3] + 0.59 * rgb[i * 3 + 1] + 0.11 * rgb[i * 3 + 2];
75
+
76
+ /**
77
+ * Did the removal leave a dark patch the card's shape (its silhouette, or Muse's repaint a shade darker than the
78
+ * painting around it)? A background that continues does not follow the outline of what was pasted: a ghost
79
+ * does, darker just inside the pasted area than just outside, all the way round, and most of it darker than
80
+ * the painting around it. `area`: what was pasted for one card (its mask, grown).
81
+ */
82
+ export function ghostOf(rgb: Uint8Array, W: number, H: number, area: Mask): { step: number; share: number; ghost: boolean } {
83
+ const inner = subtract(dilate(area, -2), dilate(area, -8));
84
+ const outer = subtract(dilate(area, 12), dilate(area, 5));
85
+ const core = dilate(area, -4);
86
+ const mean = (m: Mask) => {
87
+ let [sum, n] = [0, 0];
88
+ for (let i = 0; i < W * H; i++) if (m.bits[i]) [sum, n] = [sum + luma(rgb, i), n + 1];
89
+ return n ? sum / n : NaN;
90
+ };
91
+ const around: number[] = [];
92
+ for (let i = 0; i < W * H; i++) if (outer.bits[i]) around.push(luma(rgb, i));
93
+ around.sort((a, b) => a - b);
94
+ const median = around[around.length >> 1] ?? 0;
95
+ let [dark, n] = [0, 0];
96
+ for (let i = 0; i < W * H; i++) {
97
+ if (!core.bits[i]) continue;
98
+ n++;
99
+ if (luma(rgb, i) < median - 20) dark++;
100
+ }
101
+ const step = mean(outer) - mean(inner);
102
+ const share = n ? dark / n : 0;
103
+ return { step: Math.round(step), share: Math.round(share * 100) / 100, ghost: n > 200 && step > GHOST_STEP && share > GHOST_SHARE };
104
+ }
105
+
106
+ /**
107
+ * Muse repaints the whole picture on an edit, often a shade darker or bluer, and not evenly: pasted back
108
+ * inside a card's area that shift shows as a patch the card's shape. Where both show the same thing (a ring
109
+ * just outside the area) the difference is read, carried smoothly into the area, and added to Muse's pixels,
110
+ * so the repaint meets the painting with no seam.
111
+ */
112
+ export async function matchTone(edited: Buffer, painting: Buffer, area: Mask): Promise<Buffer> {
113
+ const { width: W, height: H } = FRAME;
114
+ const [e, p] = await Promise.all([edited, painting].map(async (b) => new Uint8Array(await sharp(b).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer())));
115
+ const ring = subtract(dilate(area, 10), area);
116
+ // The difference, halved and centred on 128 so it fits a byte; the smear fills it in from the ring.
117
+ const diff = new Uint8Array(W * H * 3).fill(128);
118
+ for (let i = 0; i < W * H; i++) if (ring.bits[i]) for (let c = 0; c < 3; c++) diff[i * 3 + c] = 128 + ((p[i * 3 + c] - e[i * 3 + c]) >> 1);
119
+ // Blurred, as the smear comes in blocks deep inside a big area.
120
+ const field = new Uint8Array(await sharp(smearFill(diff, W, H, area, ring), { raw: { width: W, height: H, channels: 3 } }).blur(8).raw().toBuffer());
121
+ const out = Uint8Array.from(e);
122
+ const reach = dilate(area, 10);
123
+ for (let i = 0; i < W * H; i++) {
124
+ if (!reach.bits[i]) continue;
125
+ for (let c = 0; c < 3; c++) out[i * 3 + c] = Math.max(0, Math.min(255, e[i * 3 + c] + 2 * (field[i * 3 + c] - 128)));
126
+ }
127
+ return sharp(out, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
128
+ }
129
+
130
+ /** Outline px of a cut where what is painted outside does not go on inside: as wide a step as this (RGB distance, blurred)... */
131
+ const SEAM_STEP = 28;
132
+ /** ...along a run this long (px of outline), or this share of the whole outline. */
133
+ const SEAM_RUN = 24;
134
+ const SEAM_SHARE = 0.06;
135
+
136
+ /** px: the longest run cut through, share: of the whole outline; seam: every outline px that steps (to draw). */
137
+ export type Seam = { px: number; share: number; box: [number, number, number, number] | null; cut: boolean; seam: Mask };
138
+
139
+ /**
140
+ * Was a thing cut short? When its mask leaves out a painted part of it (fish heads over a bucket's rim), Muse
141
+ * takes the whole thing out, but the backdrop is pasted only up to the mask (grown) and cuts that part through:
142
+ * just outside the pasted area the painting shows the part, just inside the backdrop shows what is behind.
143
+ * Read along the outline of `area` (what was pasted for one card), an outline px is cut where (1) the step
144
+ * across it in the backdrop, less the step the painting itself takes further out (texture that changes anyway),
145
+ * is large, and (2) Muse's edit took out what the painting shows just outside too: it was part of what Muse
146
+ * removed. (2) tells a cut part from a wall Muse continued a little out of line. Outline px inside `pasted` (other
147
+ * cards' areas) are no outline; below `floorY` (where the thing meets the floor) it is not read: parts stick out
148
+ * at the top and the sides, and a floor continued a little differently steps there with nothing cut.
149
+ */
150
+ export function seamOf(
151
+ img: { painting: Uint8Array; backdrop: Uint8Array; edited: Uint8Array },
152
+ W: number,
153
+ H: number,
154
+ area: Mask,
155
+ { pasted, floorY = H }: { pasted?: Mask; floorY?: number } = {},
156
+ ): Seam {
157
+ // Colour, not luma: a dark barrel on dark cobbles differs in hue.
158
+ const channels = (rgb: Uint8Array) => [0, 1, 2].map((c) => {
159
+ const out = new Float32Array(W * H);
160
+ for (let i = 0; i < W * H; i++) out[i] = rgb[i * 3 + c];
161
+ return blurred(out, W, H, 3);
162
+ });
163
+ const [P, B, E] = [channels(img.painting), channels(img.backdrop), channels(img.edited)];
164
+ // Which way is in, from the area blurred: its gradient points into it.
165
+ const a = new Float32Array(W * H);
166
+ for (let i = 0; i < W * H; i++) a[i] = area.bits[i];
167
+ const soft = blurred(a, W, H, 4);
168
+ const edge = subtract(dilate(area, 1), area);
169
+ const idx = (x: number, y: number) => Math.min(H - 1, Math.max(0, Math.round(y))) * W + Math.min(W - 1, Math.max(0, Math.round(x)));
170
+ const dist = (p: Float32Array[], i: number, q: Float32Array[], j: number) => Math.hypot(p[0][i] - q[0][j], p[1][i] - q[1][j], p[2][i] - q[2][j]);
171
+ const seam = emptyMask(W, H);
172
+ let outline = 0;
173
+ for (let i = 0; i < W * H; i++) {
174
+ if (!edge.bits[i] || pasted?.bits[i]) continue;
175
+ const [x, y] = [i % W, Math.floor(i / W)];
176
+ if (x < 2 || y < 2 || x >= W - 2 || y >= H - 2 || y > floorY) continue;
177
+ let [nx, ny] = [soft[idx(x + 1, y)] - soft[idx(x - 1, y)], soft[idx(x, y + 1)] - soft[idx(x, y - 1)]];
178
+ const len = Math.hypot(nx, ny);
179
+ if (len < 1e-4) continue;
180
+ [nx, ny] = [nx / len, ny / len];
181
+ outline++;
182
+ const inside = idx(x + 12 * nx, y + 12 * ny);
183
+ const out = idx(x - 4 * nx, y - 4 * ny);
184
+ const further = idx(x - 14 * nx, y - 14 * ny);
185
+ // A lamp's painted glow round a thing is light, not a part of it: where the painting just outside is bright
186
+ // and warm, a step there is the glow Muse took out with the lamp, and brushing it onto the card left a disc.
187
+ const glow = 0.3 * P[0][out] + 0.59 * P[1][out] + 0.11 * P[2][out] > 150 && P[0][out] - P[2][out] > 50;
188
+ if (!glow && dist(B, inside, P, out) - 0.5 * dist(P, further, P, out) > SEAM_STEP && dist(E, out, P, out) > SEAM_STEP) seam.bits[i] = 1;
189
+ }
190
+ // Runs along the outline: seam px a few apart joined (a fish tail between two heads still steps a little).
191
+ const runs = components(dilate(seam, 5)).map((c) => ({ c, px: maskArea(intersect(c, seam)) }));
192
+ const longest = runs.reduce((best, r) => (r.px > (best?.px ?? 0) ? r : best), undefined as (typeof runs)[number] | undefined);
193
+ const px = longest?.px ?? 0;
194
+ const share = outline ? px / outline : 0;
195
+ return { px, share: Math.round(share * 100) / 100, box: longest ? maskBox(longest.c) ?? null : null, cut: px >= SEAM_RUN && share >= SEAM_SHARE, seam };
196
+ }
197
+
198
+ /** A box blur three times over (close to a gaussian of about `r`). */
199
+ function blurred(img: Float32Array, W: number, H: number, r: number): Float32Array {
200
+ let cur = img;
201
+ for (let pass = 0; pass < 3; pass++) {
202
+ for (const along of ['x', 'y'] as const) {
203
+ const out = new Float32Array(W * H);
204
+ const [lines, len, step, lineStep] = along === 'x' ? [H, W, 1, W] : [W, H, W, 1];
205
+ for (let l = 0; l < lines; l++) {
206
+ const base = l * lineStep;
207
+ let sum = 0;
208
+ for (let i = -r; i <= r; i++) sum += cur[base + Math.min(len - 1, Math.max(0, i)) * step];
209
+ for (let i = 0; i < len; i++) {
210
+ out[base + i * step] = sum / (2 * r + 1);
211
+ sum += cur[base + Math.min(len - 1, i + r + 1) * step] - cur[base + Math.max(0, i - r) * step];
212
+ }
213
+ }
214
+ cur = out;
215
+ }
216
+ }
217
+ return cur;
218
+ }
219
+
220
+ async function ghostsIn(png: Buffer, cards: Card[]): Promise<Card[]> {
221
+ const { width: W, height: H } = FRAME;
222
+ const rgb = new Uint8Array(await sharp(png).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
223
+ return cards.filter((card) => ghostOf(rgb, W, H, dilate(card.mask, BACKDROP_GROW)).ghost);
224
+ }
225
+
226
+ export async function backdropFor(memo: Memo, place: Place, cards: Card[], painting: Buffer): Promise<Backdrop> {
227
+ const { width: W, height: H } = FRAME;
228
+ const area = dilate(unionAll(W, H, cards.map((c) => c.mask)), BACKDROP_GROW);
229
+ const names = cards.map((c) => `the ${c.phrase}`);
230
+ const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0];
231
+ const prompt = `Remove ${list} from this painting. Paint what is behind ${cards.length > 1 ? 'them' : 'it'} where ${cards.length > 1 ? 'they were' : 'it was'}: continue the floor, the walls and the background, in the same style and light. Leave everything else exactly as it is. No people, no text.`;
232
+ const edited = await museEdit(memo, { label: `backdrop:${place.key}`, prompt, images: [painting] });
233
+ let png = await pasteInside(painting, await matchTone(edited, painting, area), area);
234
+ const report: Backdrop['report'] = {};
235
+ const leftover: Mask[] = [];
236
+ // Is each still there? SAM by the same phrase, inside the area the backdrop replaced; all cards at once.
237
+ const seen = await Promise.all(cards.map((card) => samFind(memo, { label: `backdrop-check:${place.key}/${card.key}`, image: png, prompt: card.phrase })));
238
+ cards.forEach((card, i) => {
239
+ const found = seen[i];
240
+ const iou = Math.max(0, ...found.map((f) => maskIou(intersect(f.mask, area), card.mask)));
241
+ const left = found.reduce((px, f) => px + maskArea(intersect(f.mask, card.mask)), 0);
242
+ const stillThere = iou > 0.4 || left > 0.4 * maskArea(card.mask);
243
+ report[card.key] = { stillThere, iou: Math.round(iou * 100) / 100 };
244
+ if (stillThere) leftover.push(dilate(card.mask, BACKDROP_GROW));
245
+ });
246
+ // A card Muse left in: asked once more on a close crop round it (Muse sees it big, and the rest of the picture
247
+ // cannot pull it back in), pasted inside its area; only what SAM still finds after that is smeared.
248
+ const stuck = cards.filter((c) => report[c.key].stillThere);
249
+ if (stuck.length) {
250
+ leftover.length = 0;
251
+ for (const card of stuck) {
252
+ const fixed = await removeClose(memo, place, card, png);
253
+ if (fixed) {
254
+ const grown = dilate(card.mask, BACKDROP_GROW);
255
+ const candidate = await pasteInside(png, await matchTone(fixed, png, grown), grown);
256
+ const again = await samFind(memo, { label: `backdrop-check-close:${place.key}/${card.key}`, image: candidate, prompt: card.phrase });
257
+ const iou = Math.max(0, ...again.map((f) => maskIou(intersect(f.mask, area), card.mask)));
258
+ const left = again.reduce((px, f) => px + maskArea(intersect(f.mask, card.mask)), 0);
259
+ if (!(iou > 0.4 || left > 0.4 * maskArea(card.mask))) {
260
+ png = candidate;
261
+ report[card.key] = { stillThere: false, iou: Math.round(iou * 100) / 100, close: true };
262
+ continue;
263
+ }
264
+ }
265
+ leftover.push(dilate(card.mask, BACKDROP_GROW));
266
+ }
267
+ }
268
+ // A ghost left where a card was taken out: Muse once more on those, then the smear for any still there.
269
+ let ghosts = await ghostsIn(png, cards.filter((c) => !report[c.key].stillThere));
270
+ if (ghosts.length) {
271
+ const named = ghosts.map((c) => `the ${c.phrase}`).join(', ');
272
+ const again = await museEdit(memo, {
273
+ label: `backdrop-ghost:${place.key}`,
274
+ prompt: `A dark silhouette is left in this painting where ${named} stood. Paint over it what is behind: continue the background, the water, the floor and the light around it, with no shape, shadow or outline of ${ghosts.length > 1 ? 'them' : 'it'}. Leave everything else exactly as it is. No people, no text.`,
275
+ images: [png],
276
+ }).catch(() => undefined);
277
+ const redo = dilate(unionAll(W, H, ghosts.map((c) => c.mask)), BACKDROP_GROW);
278
+ if (again) png = await pasteInside(png, await matchTone(again, png, redo), redo);
279
+ const still = new Set((await ghostsIn(png, ghosts)).map((c) => c.key));
280
+ for (const c of ghosts) report[c.key].ghost = true;
281
+ ghosts = ghosts.filter((c) => still.has(c.key));
282
+ for (const c of ghosts) leftover.push(dilate(c.mask, BACKDROP_GROW));
283
+ }
284
+ if (leftover.length) png = await pasteInside(png, await smearedBackdrop(cards, painting), unionAll(W, H, leftover), 2);
285
+ // The gate: a card whose outline cuts through a part of its thing (its mask left that part out).
286
+ const raw = async (b: Buffer) => new Uint8Array(await sharp(b).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
287
+ const img = { painting: await raw(painting), backdrop: await raw(png), edited: await raw(edited) };
288
+ for (const card of cards) {
289
+ const box = maskBox(card.mask);
290
+ if (!box) continue;
291
+ const pasted = unionAll(W, H, cards.filter((c) => c !== card).map((c) => dilate(c.mask, BACKDROP_GROW)));
292
+ const seam = seamOf(img, W, H, dilate(card.mask, BACKDROP_GROW), { pasted, floorY: box[3] - 0.15 * (box[3] - box[1]) });
293
+ if (seam.cut) report[card.key].cut = { px: seam.px, box: seam.box };
294
+ }
295
+ return { png, report };
296
+ }
297
+
298
+ /**
299
+ * One card taken out on a close crop: the card's box with room round it, scaled up to Muse's size, edited, and
300
+ * scaled back into the whole picture. Undefined when Muse fails (the smear stays the fallback).
301
+ */
302
+ async function removeClose(memo: Memo, place: Place, card: Card, png: Buffer): Promise<Buffer | undefined> {
303
+ const { width: W, height: H } = FRAME;
304
+ const box = maskBox(card.mask);
305
+ if (!box) return undefined;
306
+ const [x0, y0, x1, y1] = box;
307
+ const side = Math.min(Math.max(W, H), Math.max(x1 - x0, y1 - y0) * 2.2);
308
+ const w = Math.round(Math.min(W, side * 1.34)), h = Math.round(Math.min(H, side));
309
+ const left = Math.round(Math.min(W - w, Math.max(0, (x0 + x1) / 2 - w / 2))), top = Math.round(Math.min(H - h, Math.max(0, (y0 + y1) / 2 - h / 2)));
310
+ const crop = await sharp(png).resize(W, H, { fit: 'fill' }).extract({ left, top, width: w, height: h }).resize(1200, 896, { fit: 'fill' }).png().toBuffer();
311
+ const edited = await museEdit(memo, {
312
+ label: `backdrop-close:${place.key}/${card.key}`, images: [crop],
313
+ prompt: `Remove the ${card.phrase} from this picture completely. Paint what is behind it where it was: continue the water, the planks, the stones, the walls and the light around it, with no trace, shadow, reflection or outline of it. Leave everything else exactly as it is, same painted style. No people, no text.`,
314
+ }).catch(() => undefined);
315
+ if (!edited) return undefined;
316
+ const back = await sharp(edited).resize(w, h, { fit: 'fill' }).png().toBuffer();
317
+ return sharp(png).resize(W, H, { fit: 'fill' }).composite([{ input: back, left, top }]).png().toBuffer();
318
+ }
319
+
320
+ /** The smeared fill alone, for a place without a key or when Muse is not wanted. */
321
+ export async function smearedBackdrop(cards: Card[], painting: Buffer): Promise<Buffer> {
322
+ const { width: W, height: H } = FRAME;
323
+ const area = dilate(unionAll(W, H, cards.map((c) => c.mask)), BACKDROP_GROW);
324
+ const rgb = new Uint8Array(await sharp(painting).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
325
+ return sharp(smearFill(rgb, W, H, area), { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
326
+ }
@@ -0,0 +1,189 @@
1
+ // blockin.png: the layout as a shaded sketch. Muse keeps where things are and paints what they are.
2
+ // Flat soft boxes told Muse only "something goes here", so it moved and resized them freely: a stage floor
3
+ // with a vanishing grid, a silhouette per kind of thing with its top lit and its shadow on the floor say
4
+ // how deep each thing stands and what it roughly is (ootb measure 10-28: median IoU of painted things
5
+ // against the plan was 0.25 with boxes).
6
+ import sharp from 'sharp';
7
+ import type { Place } from '../world/schema.ts';
8
+
9
+ type Thing = Place['things'][number];
10
+ import { FRAME, type Layout, type Shape } from './layout.ts';
11
+
12
+ type Box = Layout['things'][number];
13
+
14
+ const WORDS: [Shape, RegExp][] = [
15
+ ['steps', /\b(steps?|stairs?|staircase|stairway|ladder)(e?s)?\b/],
16
+ ['flat', /\b(rug|carpet|water|pond|puddle|pool|tracks?|rails?|path|road|mat|stream|canal|river)(e?s)?\b/],
17
+ ['arch', /\b(door|doorway|gate|arch|archway|tunnel|portal|entrance|passage|window)(e?s)?\b/],
18
+ ['tree', /\b(tree|willow|oak|pine|birch|palm|grove|orchard)(e?s)?\b/],
19
+ ['post', /\b(lamp|lantern post|street ?light|post|pole|sign ?post|mast|torch|column|pillar|chimney)(e?s)?\b/],
20
+ ['round', /\b(barrel|pot|jar|urn|bucket|basket|rock|boulder|bush|shrub|reeds?|well|cauldron|sack|haystack|pumpkin|crock)(e?s)?\b/],
21
+ ['low', /\b(bench|boat|cart|bed|sofa|couch|table|desk|trough|log|bridge|crate|chest|box)(e?s)?\b/],
22
+ ];
23
+
24
+ /**
25
+ * Layouts laid out before Spark named shapes (or a shape it left out) get one from the thing's words:
26
+ * its key first ("curving-tram-tracks" lie flat though their looks run "into a dark arch"), then its looks.
27
+ */
28
+ export function shapeOf(box: Box, thing?: Thing): Shape {
29
+ if (box.shape) return box.shape;
30
+ for (const words of [box.key.replace(/-/g, ' '), (thing?.looks ?? '').toLowerCase()])
31
+ for (const [shape, re] of WORDS) if (re.test(words)) return shape;
32
+ return box.height > 2.5 * box.width ? 'post' : box.width > 2 * box.height ? 'low' : 'block';
33
+ }
34
+
35
+ const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
36
+ /** k > 0 towards white, k < 0 towards black. */
37
+ export function shade(colour: string, k: number): string {
38
+ const to = k > 0 ? 255 : 0;
39
+ return `#${hex(colour).map((v) => Math.round(v + (to - v) * Math.abs(k)).toString(16).padStart(2, '0')).join('')}`;
40
+ }
41
+
42
+ /** How much of a thing's top the stage camera sees: none at the horizon, more the nearer it stands. */
43
+ const topOf = (layout: Layout, b: Box) => Math.max(4, Math.min(0.35 * b.height, 0.22 * b.width * ((b.footY - layout.horizon) / (FRAME.height - layout.horizon))));
44
+
45
+ /** One thing as a silhouette standing on the floor: body, lit top, a dark rim, its shadow first. */
46
+ function thingSvg(layout: Layout, b: Box, shape: Shape, i: number): string {
47
+ const { x, footY: y, width: w, height: h, colour: c } = b;
48
+ const l = x - w / 2, r = x + w / 2, t = y - h;
49
+ const rim = `stroke="${shade(c, -0.55)}" stroke-width="3" stroke-linejoin="round"`;
50
+ const body = `fill="url(#b${i})" ${rim}`;
51
+ const lit = `fill="${shade(c, 0.32)}" ${rim}`;
52
+ const dark = shade(c, -0.6);
53
+ const grad = `<linearGradient id="b${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${shade(c, 0.12)}"/><stop offset="1" stop-color="${shade(c, -0.28)}"/></linearGradient>`;
54
+ const shadow = shape === 'flat' ? '' : `<ellipse cx="${x + w * 0.06}" cy="${y}" rx="${w * 0.62}" ry="${Math.max(6, w * 0.11)}" fill="#000" fill-opacity="0.38" filter="url(#soft)"/>`;
55
+ const top = topOf(layout, b);
56
+ const parts: string[] = [];
57
+ switch (shape) {
58
+ case 'flat': {
59
+ // Lies on the floor: a squashed oval, seen from above.
60
+ const d = Math.max(10, Math.min(h, w * 0.35));
61
+ parts.push(`<ellipse cx="${x}" cy="${y - d / 2}" rx="${w / 2}" ry="${d / 2}" fill="${c}" ${rim}/>`, `<ellipse cx="${x - w * 0.08}" cy="${y - d * 0.62}" rx="${w * 0.3}" ry="${d * 0.18}" fill="${shade(c, 0.25)}"/>`);
62
+ break;
63
+ }
64
+ case 'post': {
65
+ const pole = Math.max(8, w * 0.22), head = Math.min(w, h * 0.3);
66
+ parts.push(`<rect x="${x - pole / 2}" y="${t + head * 0.7}" width="${pole}" height="${h - head * 0.7}" ${body}/>`,
67
+ `<rect x="${x - pole}" y="${y - pole * 0.8}" width="${pole * 2}" height="${pole * 0.8}" ${body}/>`,
68
+ `<rect x="${x - head / 2}" y="${t}" width="${head}" height="${head * 0.9}" rx="${head * 0.2}" ${lit}/>`);
69
+ break;
70
+ }
71
+ case 'tree': {
72
+ const trunk = Math.max(10, w * 0.16);
73
+ parts.push(`<rect x="${x - trunk / 2}" y="${t + h * 0.45}" width="${trunk}" height="${h * 0.55}" fill="${dark}" ${rim}/>`,
74
+ `<ellipse cx="${x}" cy="${t + h * 0.33}" rx="${w / 2}" ry="${h * 0.33}" ${body}/>`,
75
+ `<ellipse cx="${x - w * 0.12}" cy="${t + h * 0.22}" rx="${w * 0.26}" ry="${h * 0.14}" fill="${shade(c, 0.25)}"/>`);
76
+ break;
77
+ }
78
+ case 'arch': {
79
+ // A frame with a dark opening: a door, gate or passage leads somewhere.
80
+ const ro = w / 2, fw = Math.max(8, w * 0.16);
81
+ const ri = ro - fw;
82
+ parts.push(`<path d="M${l},${y} L${l},${t + ro} A${ro},${ro} 0 0 1 ${r},${t + ro} L${r},${y} Z" ${body}/>`,
83
+ `<path d="M${l + fw},${y} L${l + fw},${t + ro + fw * 0.4} A${ri},${ri} 0 0 1 ${r - fw},${t + ro + fw * 0.4} L${r - fw},${y} Z" fill="${dark}"/>`);
84
+ break;
85
+ }
86
+ case 'round': {
87
+ const e = Math.max(6, Math.min(top, w * 0.18));
88
+ parts.push(`<path d="M${l},${t + e} L${l},${y - e} A${w / 2},${e} 0 0 0 ${r},${y - e} L${r},${t + e} Z" ${body}/>`,
89
+ `<ellipse cx="${x}" cy="${t + e}" rx="${w / 2}" ry="${e}" ${lit}/>`);
90
+ break;
91
+ }
92
+ case 'steps': {
93
+ const n = 4, rise = h / n;
94
+ for (let s = 0; s < n; s++) {
95
+ const inset = (w * 0.12 * s) / n;
96
+ const sy = y - (s + 1) * rise;
97
+ parts.push(`<rect x="${l + inset}" y="${sy}" width="${w - 2 * inset}" height="${rise}" fill="${shade(c, -0.25 + 0.12 * s)}" ${rim}/>`,
98
+ `<rect x="${l + inset + 3}" y="${sy + 2}" width="${w - 2 * inset - 6}" height="${Math.max(3, rise * 0.25)}" fill="${shade(c, 0.3)}"/>`);
99
+ }
100
+ break;
101
+ }
102
+ default: {
103
+ // block and low: a box with its top seen from a little above.
104
+ const slant = top * 0.5;
105
+ parts.push(`<rect x="${l}" y="${t + top}" width="${w}" height="${h - top}" ${body}/>`,
106
+ `<path d="M${l},${t + top} L${l + slant},${t} L${r - slant},${t} L${r},${t + top} Z" ${lit}/>`);
107
+ }
108
+ }
109
+ return `<defs>${grad}</defs>${shadow}${parts.join('')}`;
110
+ }
111
+
112
+ /** A floor tiled towards the vanishing point, so it reads as ground going back, not a coloured band. */
113
+ function floorSvg(layout: Layout, f: Layout['floors'][number], i: number): string {
114
+ const { width: W, height: H } = FRAME;
115
+ const vx = W / 2, vy = layout.horizon;
116
+ const pts = f.outline.map((p) => p.join(',')).join(' ');
117
+ const lines: string[] = [];
118
+ for (let k = -8; k <= 8; k++) lines.push(`M${vx},${vy} L${vx + k * W * 0.32},${H + 400}`);
119
+ // Rows close up towards the horizon as perspective does.
120
+ for (let d = 1; d <= 14; d++) {
121
+ const y = vy + (H - vy) * (1 / (1 + 0.45 * (14 - d))) ;
122
+ lines.push(`M0,${y} L${W},${y}`);
123
+ }
124
+ return `<clipPath id="f${i}"><polygon points="${pts}"/></clipPath>
125
+ <polygon points="${pts}" fill="${f.colour}"/>
126
+ <path d="${lines.join(' ')}" stroke="${shade(f.colour, -0.35)}" stroke-width="2" stroke-opacity="0.35" fill="none" clip-path="url(#f${i})"/>
127
+ <polygon points="${pts}" fill="none" stroke="${shade(f.colour, 0.3)}" stroke-width="3" stroke-opacity="0.8"/>`;
128
+ }
129
+
130
+ /** NPCs get a spot in the layout but no mass: a character painted into the background can't move. */
131
+ export function blockinSvg(layout: Layout, place: Place): string {
132
+ const { width: W, height: H } = FRAME;
133
+ const npcs = new Set(place.things.filter((t) => t.role === 'npc').map((t) => t.key));
134
+ const things = layout.things
135
+ .filter((t) => !npcs.has(t.key))
136
+ .sort((a, b) => a.footY - b.footY) // far first, near on top
137
+ .map((t, i) => thingSvg(layout, t, shapeOf(t, place.things.find((p) => p.key === t.key)), i));
138
+ const glows = layout.lights.map((l, i) => `<radialGradient id="g${i}"><stop offset="0" stop-color="${l.colour}" stop-opacity="0.9"/><stop offset="0.25" stop-color="${l.colour}" stop-opacity="0.5"/><stop offset="1" stop-color="${l.colour}" stop-opacity="0"/></radialGradient><circle cx="${l.x}" cy="${l.y}" r="80" fill="url(#g${i})"/><circle cx="${l.x}" cy="${l.y}" r="9" fill="#fffbe8"/>`);
139
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
140
+ <defs><filter id="soft"><feGaussianBlur stdDeviation="6"/></filter>
141
+ <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${shade(layout.sky, -0.25)}"/><stop offset="1" stop-color="${shade(layout.sky, 0.18)}"/></linearGradient>
142
+ <linearGradient id="ground" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${shade(layout.ground, 0.12)}"/><stop offset="1" stop-color="${shade(layout.ground, -0.3)}"/></linearGradient></defs>
143
+ <rect width="${W}" height="${layout.horizon}" fill="url(#sky)"/>
144
+ <rect y="${layout.horizon}" width="${W}" height="${H - layout.horizon}" fill="url(#ground)"/>
145
+ <path d="M0,${layout.horizon} L${W},${layout.horizon}" stroke="${shade(layout.ground, -0.45)}" stroke-width="3"/>
146
+ ${[...layout.floors.map((f, i) => floorSvg(layout, f, i)), ...things, ...glows].join('\n')}
147
+ </svg>`;
148
+ }
149
+
150
+ /** Barely softened: the silhouettes are meant to be followed, only their hard pixel edges are not. */
151
+ export function renderBlockin(layout: Layout, place: Place): Promise<Buffer> {
152
+ return sharp(Buffer.from(blockinSvg(layout, place))).blur(1.5).png().toBuffer();
153
+ }
154
+
155
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
156
+
157
+ /**
158
+ * plan.png, for people: the block-in with every thing and floor named, as a plan sheet reads. Muse never sees
159
+ * it (it would paint the words); Studio and the film show it, so the shapes read as a deliberate plan.
160
+ */
161
+ export function planSvg(layout: Layout, place: Place, names: Record<string, string> = {}): string {
162
+ const { width: W, height: H } = FRAME;
163
+ const name = (key: string) => esc(names[key] ?? key.replace(/-/g, ' '));
164
+ const tag = (x: number, y: number, text: string, big: boolean) => {
165
+ const size = big ? 26 : 21, w = text.length * size * 0.56 + 24;
166
+ const cx = Math.min(W - w / 2 - 6, Math.max(w / 2 + 6, x));
167
+ return `<g font-family="Inter, Helvetica, Arial, sans-serif" font-size="${size}" font-weight="${big ? 600 : 500}">
168
+ <rect x="${cx - w / 2}" y="${y - size - 4}" width="${w}" height="${size + 14}" rx="${(size + 14) / 2}" fill="${big ? '#fffaf0' : '#1d1b22'}" fill-opacity="${big ? 0.94 : 0.72}"/>
169
+ <text x="${cx}" y="${y + 1}" text-anchor="middle" fill="${big ? '#2a2420' : '#f4efe6'}"${big ? '' : ' font-style="italic"'}>${text}</text></g>`;
170
+ };
171
+ const npcs = new Set(place.things.filter((t) => t.role === 'npc').map((t) => t.key));
172
+ const things = layout.things.map((t) => {
173
+ if (npcs.has(t.key)) {
174
+ // A person is painted later, on their own: a dashed spot where they will stand.
175
+ return `<ellipse cx="${t.x}" cy="${t.footY}" rx="${t.width / 2}" ry="${Math.max(8, t.width * 0.14)}" fill="none" stroke="#fffaf0" stroke-width="3" stroke-dasharray="8 7"/>${tag(t.x, t.footY - 14, name(t.key), true)}`;
176
+ }
177
+ const top = shapeOf(t, place.things.find((p) => p.key === t.key)) === 'flat' ? t.footY - Math.max(10, Math.min(t.height, t.width * 0.35)) : t.footY - t.height;
178
+ return `<line x1="${t.x}" y1="${top - 6}" x2="${t.x}" y2="${top + 10}" stroke="#fffaf0" stroke-width="2"/>${tag(t.x, Math.max(30, top - 12), name(t.key), true)}`;
179
+ });
180
+ const floors = layout.floors.map((f) => {
181
+ const xs = f.outline.map(([x]) => x), ys = f.outline.map(([, y]) => y);
182
+ return tag((Math.min(...xs) + Math.max(...xs)) / 2, Math.max(...ys) - 18, name(f.key), false);
183
+ });
184
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">${[...floors, ...things].join('\n')}</svg>`;
185
+ }
186
+
187
+ export function renderPlan(layout: Layout, place: Place, blockin: Buffer, names?: Record<string, string>): Promise<Buffer> {
188
+ return sharp(blockin).resize(FRAME.width, FRAME.height, { fit: 'fill' }).composite([{ input: Buffer.from(planSvg(layout, place, names)) }]).png().toBuffer();
189
+ }
@@ -0,0 +1,74 @@
1
+ // brief.json: Spark describes how each thing and floor should look before Muse paints it.
2
+ // Muse turns undescribed masses into flat walls and posts, so every mass gets a concrete look.
3
+ import { z } from 'zod';
4
+ import type { Memo } from '../meta/memo.ts';
5
+ import { askSparkJson } from '../meta/spark.ts';
6
+ import type { Place, World } from '../world/schema.ts';
7
+ import type { Layout } from './layout.ts';
8
+ import { SAFE_WORDS } from './words.ts';
9
+
10
+ export const Brief = z.object({
11
+ looks: z.record(z.string(), z.string().min(10)),
12
+ /** Things that move in the game (water, fire, smoke, animals); painted still, animated later. */
13
+ alive: z.array(z.object({ key: z.string(), as: z.enum(['water', 'fire', 'smoke', 'animal', 'cloth', 'light']) })),
14
+ });
15
+ export type Brief = z.infer<typeof Brief>;
16
+
17
+ /** Animals that are things of the place: painted into the background, so the painter must not leave them out. */
18
+ export const paintedAnimals = (brief?: Brief) => (brief?.alive ?? []).filter((a) => a.as === 'animal').map((a) => a.key);
19
+
20
+ export function briefIssues(brief: Brief, place: Place, layout: Layout): string[] {
21
+ const keys = [...place.things.filter((t) => t.role !== 'npc').map((t) => t.key), ...layout.floors.map((f) => f.key)];
22
+ return keys.filter((k) => !brief.looks[k]).map((k) => `no look for ${k}`);
23
+ }
24
+
25
+ /** The last word of a cast member's key names what it is ("harbour-cat" → cat, "old-ferryman" → ferryman). */
26
+ const castWord = (key: string) => key.split('-').filter((w) => w.length >= 3).pop();
27
+
28
+ /**
29
+ * Looks that paint one of the place's cast into a thing ("a basket holding a curled-up ginger cat" with a cat
30
+ * in the cast): the game draws its cast over the picture, so that one would be a double. Only when the thing's
31
+ * own words did not name it (its key does not count: a cat-basket is the cat's bed, not a cat).
32
+ */
33
+ export function castInLooks(brief: Brief, place: Place): string[] {
34
+ const cast = place.things.filter((t) => t.role === 'npc').map((t) => castWord(t.key)).filter((w): w is string => !!w);
35
+ const out: string[] = [];
36
+ for (const t of place.things.filter((t) => t.role !== 'npc')) {
37
+ const look = brief.looks[t.key];
38
+ if (!look) continue;
39
+ for (const w of cast) {
40
+ const named = new RegExp(`\\b${w}s?\\b`, 'i');
41
+ if (named.test(look) && !named.test(t.looks ?? '')) out.push(`${t.key}'s look paints the ${w}, who is in the cast: leave the ${w} out of it`);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export async function writeBrief(memo: Memo, world: World, place: Place, layout: Layout): Promise<Brief> {
48
+ const cast = place.things.filter((t) => t.role === 'npc').map((t) => `${t.key}${t.looks ? ` (${t.looks})` : ''}`);
49
+ const subjects = [
50
+ ...place.things.filter((t) => t.role !== 'npc').map((t) => `${t.key}: ${t.looks} (${t.role})`),
51
+ ...layout.floors.map((f) => `${f.key}: ${f.is} (floor)`),
52
+ ];
53
+ const prompt = `You brief a painter for one background of a 2D point-and-click adventure.
54
+
55
+ Style: ${world.style}
56
+ Place: ${place.shows}
57
+ Mood: ${place.mood}
58
+
59
+ For each subject below write its look in one sentence: shape, material, surface texture, wear, colour in this light. Be concrete enough that nobody could paint it as a plain block.
60
+ ${subjects.map((s) => `- ${s}`).join('\n')}
61
+
62
+ ${SAFE_WORDS}
63
+
64
+ Also list which subjects move in the game: water that flows, fire, smoke, animals, cloth in wind, flickering light. They are painted still and animated later, so their look must describe them at rest. An animal among the subjects is painted in, standing still, lying or grazing: describe the animal itself, never an empty spot (nobody else will draw it).
65
+
66
+ ${cast.length ? `The game draws these characters over the picture itself, walking and talking: ${cast.join('; ')}. Never paint them, nor one like them, into any subject's look (no cat curled in a basket that is in this list), even where the place's words put them there: the subject is shown empty, ready for them.\n\n` : ''}Reply with JSON only: {"looks": {"<key>": "..."}, "alive": [{"key": "<key>", "as": "water | fire | smoke | animal | cloth | light"}]}`;
67
+ let brief = Brief.parse(await askSparkJson(memo, { label: `brief:${place.key}`, prompt }));
68
+ // A cast member painted into a thing anyway: asked once more, saying which.
69
+ const doubles = castInLooks(brief, place);
70
+ if (doubles.length) brief = Brief.parse(await askSparkJson(memo, { label: `brief:${place.key}:no-cast`, prompt: `${prompt}\n\nAn earlier answer did this, which is wrong: ${doubles.join('; ')}.` }));
71
+ const issues = briefIssues(brief, place, layout);
72
+ if (issues.length) throw new Error(`brief for ${place.key}: ${issues.join('; ')}`);
73
+ return brief;
74
+ }