@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,55 @@
1
+ // What a character holds or carries as part of who they are (a giant slice of toast over their head, a lantern, a
2
+ // net of fish): named from their description, found by SAM on its own and kept on the cut-out. SAM's "person" is
3
+ // only the body, and the plain-ground heuristics in warp.ts (silhouette, companions) drop anything bigger than the
4
+ // figure as the scene behind it, so a prop larger than its holder needs its own mask.
5
+ import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
6
+ import { dilate, maskBox, subtract, union } from '../image/raster.ts';
7
+ import type { Memo } from '../meta/memo.ts';
8
+ import { samFind } from '../meta/sam.ts';
9
+ import { askSparkJson } from '../meta/spark.ts';
10
+
11
+ /** Words that say the description has something in hand; without one no Spark call is made. */
12
+ const HOLDING = /\b(hold|holds|holding|held|carr(y|ies|ying|ied)|clutch\w*|grip\w*|grasp\w*|wield\w*|lift\w*|rais\w*|balanc\w*|hug\w*|cradl\w*|bear(s|ing)?|tow\w*|drag\w*|push\w*|pull\w*|swing\w*|brandish\w*|in (his|her|their|its|one|both) (hand|hands|arms|paws|claws|wings|beak|mouth))\b/i;
13
+
14
+ /**
15
+ * The things the character holds, carries or lifts, as short phrases SAM can search ("slice of toast", "lantern").
16
+ * `holds` (cast/fixes.json, set by the person) wins; otherwise one memoized Spark call on the description, and none
17
+ * at all when it says nothing about holding. Clothing and what is worn is never in the list: SAM's person has it.
18
+ */
19
+ export async function heldThings(memo: Memo, who: { key: string; looks: string; holds?: string[] }): Promise<string[]> {
20
+ if (who.holds) return who.holds.map((h) => h.trim()).filter(Boolean);
21
+ if (!HOLDING.test(who.looks)) return [];
22
+ const a = await askSparkJson<{ held?: unknown }>(memo, {
23
+ label: `figure-held:${who.key}`,
24
+ prompt: `A character of a 2D adventure game is described like this:\n"${who.looks}"\n\nList the separate objects the character holds, carries, lifts or balances (in hands, arms, paws, beak or on the head as a load), as short concrete noun phrases of 1 to 4 words that an image segmentation model can search for, e.g. "slice of toast", "lantern", "fishing net". Never list clothing, a hat, shoes, a bag strap worn on the body, hair, body parts, or a face or pattern drawn on the object itself. If they hold nothing, the list is empty.\n\nReply with JSON only: {"held": ["..."]}`,
25
+ });
26
+ return Array.isArray(a.held) ? a.held.filter((h): h is string => typeof h === 'string' && !!h.trim()).map((h) => h.trim()).slice(0, 3) : [];
27
+ }
28
+
29
+ /**
30
+ * Every instance SAM finds of the held things that touches the figure (or comes within a few percent of its height),
31
+ * outside SAM's person, as one mask; undefined when there is none. One SAM call per phrase, memoized with the image.
32
+ */
33
+ export async function heldMask(memo: Memo, key: string, image: Buffer, person: Mask, phrases: string[]): Promise<Mask | undefined> {
34
+ const box = maskBox(person);
35
+ if (!box || !phrases.length) return undefined;
36
+ const near = dilate(person, Math.max(3, Math.round(0.04 * (box[3] - box[1]))));
37
+ let out: Mask | undefined;
38
+ for (const prompt of phrases) {
39
+ for (const found of await samFind(memo, { label: `figure:${key}:held`, image, prompt })) {
40
+ const own = subtract(found.mask, person);
41
+ if (maskArea(own) < 0.01 * maskArea(person)) continue;
42
+ if (!own.bits.some((b, i) => b && near.bits[i])) continue;
43
+ out = union(out ?? emptyMask(person.width, person.height), own);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** The share of the cut-out's height the body takes, its feet at the bottom: a prop held over the head makes it less than 1. */
50
+ export function bodyShare(person: Mask, whole: Mask): number {
51
+ const p = maskBox(person);
52
+ const w = maskBox(whole);
53
+ if (!p || !w) return 1;
54
+ return Math.min(1, Math.round(((p[3] - p[1]) / Math.max(1, w[3] - w[1])) * 1000) / 1000);
55
+ }
@@ -0,0 +1,185 @@
1
+ // icons: a picture per inventory item, and only for the items the story has. An item the hero picks up in a
2
+ // place is cut out of that place's painting with the thing's SAM mask (no model call); one that is too small
3
+ // there, or that only a person hands over, is painted by Muse in the world's style and cut out by SAM.
4
+ import fs from 'node:fs';
5
+ import sharp from 'sharp';
6
+ import { z } from 'zod';
7
+ import { emptyMask, imageSize, maskArea, maskFromPng, maskToPng, type Mask } from '../image/mask.ts';
8
+ import { clampRect, components, dilate, intersect, maskBox, subtract } from '../image/raster.ts';
9
+ import type { Memo } from '../meta/memo.ts';
10
+ import { museEdit, musePaint } from '../meta/muse.ts';
11
+ import { samFind } from '../meta/sam.ts';
12
+ import { carriesOff } from '../story/engine.ts';
13
+ import type { Story } from '../story/schema.ts';
14
+ import type { WorldFiles } from '../world/files.ts';
15
+ import type { World } from '../world/schema.ts';
16
+
17
+ /** Icons are square, this many px a side; the item fills ICON_FILL of it. */
18
+ export const ICON_PX = 192;
19
+ const ICON_FILL = 168;
20
+ /** A cut-out whose longer side is shorter than this in the painting looks blurry blown up: Muse redraws it. */
21
+ export const MIN_CUT_PX = 56;
22
+
23
+ /**
24
+ * Where the hero gets each item: picked up from a thing in a place (the thing of the same key, the one the painting
25
+ * shows), or handed over (a talk, a use, a choice, or found in something else, like a fish taken from the stalls).
26
+ */
27
+ export function itemSources(story: Story): Record<string, { place: string; thing: string } | null> {
28
+ return Object.fromEntries(story.items.map((item) => {
29
+ const take = story.interactions.find((it) => it.thing === item.key && carriesOff(it));
30
+ return [item.key, take ? { place: take.place, thing: take.thing } : null];
31
+ }));
32
+ }
33
+
34
+ export const IconFixes = z.record(z.string(), z.object({
35
+ /** cut: from the painting; paint: Muse draws it. Unset, the step decides. */
36
+ how: z.enum(['cut', 'paint']).optional(),
37
+ /** The person's words on how it should look, for Muse. */
38
+ hint: z.string().min(3).optional(),
39
+ take: z.number().int().min(0).optional(),
40
+ }));
41
+ export type IconFixes = z.infer<typeof IconFixes>;
42
+
43
+ export const iconFixesFile = (files: WorldFiles) => files.path('items', 'fixes.json');
44
+
45
+ export function readIconFixes(files: WorldFiles): IconFixes {
46
+ const file = iconFixesFile(files);
47
+ return fs.existsSync(file) ? IconFixes.parse(files.readJson(file)) : {};
48
+ }
49
+
50
+ export const ItemsInfo = z.record(z.string(), z.object({
51
+ icon: z.string(),
52
+ how: z.enum(['cut', 'redraw', 'paint']),
53
+ from: z.object({ place: z.string(), thing: z.string() }).nullable(),
54
+ }));
55
+ export type ItemsInfo = z.infer<typeof ItemsInfo>;
56
+
57
+ /** The image inside `mask`, transparent elsewhere, trimmed and centred on a square icon. */
58
+ export async function cutIcon(image: Buffer, mask: Mask, pad = 3): Promise<Buffer> {
59
+ const box = maskBox(mask);
60
+ if (!box) throw new Error('empty mask');
61
+ const [x0, y0, x1, y1] = clampRect([box[0] - pad, box[1] - pad, box[2] + pad, box[3] + pad], mask.width, mask.height);
62
+ const alpha = await sharp(await maskToPng(mask)).blur(0.8).extractChannel(0).raw().toBuffer();
63
+ const rgba = await sharp(image).resize(mask.width, mask.height, { fit: 'fill' }).ensureAlpha().raw().toBuffer();
64
+ for (let i = 0; i < mask.width * mask.height; i++) rgba[i * 4 + 3] = alpha[i];
65
+ const piece = await sharp(rgba, { raw: { width: mask.width, height: mask.height, channels: 4 } })
66
+ .extract({ left: x0, top: y0, width: x1 - x0, height: y1 - y0 }).png().toBuffer();
67
+ const fitted = await sharp(piece).resize(ICON_FILL, ICON_FILL, { fit: 'inside', kernel: 'lanczos3' }).png().toBuffer();
68
+ return sharp({ create: { width: ICON_PX, height: ICON_PX, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
69
+ .composite([{ input: fitted, gravity: 'centre' }]).png().toBuffer();
70
+ }
71
+
72
+ /**
73
+ * Cuts the item out of Muse's picture: SAM by its name, else as "object" (a name like "stale buns" may mean nothing to
74
+ * it), else everything that stands out from the plain ground Muse was asked for, read from the picture's border.
75
+ */
76
+ async function samCut(memo: Memo, label: string, picture: Buffer, phrase: string): Promise<Buffer> {
77
+ const mask = await offGround(picture);
78
+ for (const prompt of [phrase, 'object']) {
79
+ const found = await samFind(memo, { label, image: picture, prompt });
80
+ const best = found.sort((a, b) => maskArea(b.mask) - maskArea(a.mask))[0];
81
+ if (!best) continue;
82
+ // SAM may name a part ("hot tea" is the tea in the cup): when what stands out from the ground holds SAM's find
83
+ // and is much bigger, the item is all of that.
84
+ const whole = mask && maskArea(intersect(mask, best.mask)) > 0.8 * maskArea(best.mask) && maskArea(mask) > 1.5 * maskArea(best.mask);
85
+ return cutIcon(picture, await peelGround(picture, whole ? mask : best.mask));
86
+ }
87
+ if (!mask) throw new Error(`${label}: SAM did not find "${phrase}" on Muse's picture, and nothing stands out from its ground`);
88
+ return cutIcon(picture, await peelGround(picture, mask));
89
+ }
90
+
91
+ /**
92
+ * SAM's mask of a thing Muse painted often takes in the wash or shadow it stands on, a halo in the bag. Pixels at the
93
+ * mask's edge coloured like what lies just outside it are peeled off, a few px at a time; the thing keeps its holes filled.
94
+ */
95
+ export async function peelGround(picture: Buffer, mask: Mask, rounds = 6): Promise<Mask> {
96
+ const { width: W, height: H } = mask;
97
+ const px = await sharp(picture).removeAlpha().resize(W, H, { fit: 'fill' }).raw().toBuffer();
98
+ const near = (i: number, c: number[]) => Math.hypot(px[i * 3] - c[0], px[i * 3 + 1] - c[1], px[i * 3 + 2] - c[2]) < 36;
99
+ let m = mask;
100
+ for (let r = 0; r < rounds; r++) {
101
+ const ring = subtract(dilate(m, 4), m);
102
+ const v: number[][] = [[], [], []];
103
+ for (let i = 0; i < W * H; i++) if (ring.bits[i]) for (let c = 0; c < 3; c++) v[c].push(px[i * 3 + c]);
104
+ if (!v[0].length) break;
105
+ const ground = v.map((a) => a.sort((x, y) => x - y)[a.length >> 1]);
106
+ const edge = subtract(m, dilate(m, -4));
107
+ const next = { ...m, bits: m.bits.slice() };
108
+ let peeled = 0;
109
+ for (let i = 0; i < W * H; i++) if (edge.bits[i] && near(i, ground)) (next.bits[i] = 0, peeled++);
110
+ if (!peeled) break;
111
+ const blob = components(next)[0];
112
+ // Never peel the thing itself away: stop where most of it would go.
113
+ if (!blob || maskArea(blob) < maskArea(mask) * 0.4) break;
114
+ m = blob;
115
+ }
116
+ return fillHoles(m);
117
+ }
118
+
119
+ function fillHoles(m: Mask): Mask {
120
+ const { width: W, height: H } = m;
121
+ const out = { ...m, bits: m.bits.slice() };
122
+ const outside = { ...m, bits: m.bits.map((b) => 1 - b) };
123
+ for (const hole of components(outside)) {
124
+ const box = maskBox(hole)!;
125
+ if (box[0] > 0 && box[1] > 0 && box[2] < W && box[3] < H) for (let i = 0; i < W * H; i++) if (hole.bits[i]) out.bits[i] = 1;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** The largest blob whose colour differs from the picture's border (the plain ground), or null. */
131
+ export async function offGround(picture: Buffer): Promise<Mask | null> {
132
+ const { data, info } = await sharp(picture).removeAlpha().resize(400, 400, { fit: 'inside' }).raw().toBuffer({ resolveWithObject: true });
133
+ const { width: w, height: h } = info;
134
+ const border: number[][] = [[], [], []];
135
+ for (let x = 0; x < w; x++) for (const y of [0, 1, h - 2, h - 1]) for (let c = 0; c < 3; c++) border[c].push(data[(y * w + x) * 3 + c]);
136
+ for (let y = 0; y < h; y++) for (const x of [0, 1, w - 2, w - 1]) for (let c = 0; c < 3; c++) border[c].push(data[(y * w + x) * 3 + c]);
137
+ const ground = border.map((v) => v.sort((a, b) => a - b)[v.length >> 1]);
138
+ const dist = new Float32Array(w * h);
139
+ for (let i = 0; i < w * h; i++) dist[i] = Math.hypot(data[i * 3] - ground[0], data[i * 3 + 1] - ground[1], data[i * 3 + 2] - ground[2]);
140
+ // Muse's "plain" ground is often a wash with its own shades: the cut is where the distances split in two (Otsu),
141
+ // never closer to the ground than 40.
142
+ const cut = Math.max(40, otsu(dist));
143
+ const small = emptyMask(w, h);
144
+ for (let i = 0; i < w * h; i++) if (dist[i] > cut) small.bits[i] = 1;
145
+ const blob = components(small).sort((a, b) => maskArea(b) - maskArea(a))[0];
146
+ if (!blob || maskArea(blob) < w * h * 0.01) return null;
147
+ const full = await imageSize(picture);
148
+ return maskFromPng(await sharp(await maskToPng(blob)).resize(full.width, full.height, { fit: 'fill' }).png().toBuffer());
149
+ }
150
+
151
+ /** The threshold that best splits `values` (0-442) into two groups. */
152
+ function otsu(values: Float32Array): number {
153
+ const bins = new Float64Array(443);
154
+ for (const v of values) bins[Math.min(442, Math.round(v))]++;
155
+ let total = 0, sum = 0;
156
+ for (let t = 0; t < bins.length; t++) (total += bins[t], sum += t * bins[t]);
157
+ let best = 0, at = 0, n0 = 0, s0 = 0;
158
+ for (let t = 0; t < bins.length; t++) {
159
+ n0 += bins[t];
160
+ s0 += t * bins[t];
161
+ const n1 = total - n0;
162
+ if (!n0 || !n1) continue;
163
+ const between = n0 * n1 * (s0 / n0 - (sum - s0) / n1) ** 2;
164
+ if (between > best) (best = between, at = t);
165
+ }
166
+ return at;
167
+ }
168
+
169
+ const look = (name: string, hint?: string) => (hint ? `${name} (${hint})` : name);
170
+
171
+ /** Muse redraws a small cut-out larger: same thing, same style, alone on a plain ground. */
172
+ export async function redrawIcon(memo: Memo, world: World, item: { key: string; name: string }, crop: Buffer, hint?: string, take = 0): Promise<Buffer> {
173
+ const prompt = `This is a small ${item.name} cut from a painting. Paint the same ${look(item.name, hint)} large, whole and centred, alone on a plain flat pale background with no shadow or ground under it, `
174
+ + `as an inventory icon for a point-and-click adventure game. Keep its shape, colours and details, in this style: ${world.style}. No hands, no people, no text, no frame.`;
175
+ const picture = await museEdit(memo, { label: `icon:${item.key}:redraw`, prompt, images: [crop], take });
176
+ return samCut(memo, `icon:${item.key}`, picture, item.name);
177
+ }
178
+
179
+ /** Muse paints an item nobody can see in any place. */
180
+ export async function paintIcon(memo: Memo, world: World, item: { key: string; name: string }, hint?: string, take = 0): Promise<Buffer> {
181
+ const prompt = `A single ${look(item.name, hint)}, whole and centred, alone on a plain flat pale background with no shadow or ground under it, as an inventory icon for a point-and-click adventure game `
182
+ + `set in "${world.title}". Style: ${world.style}. No hands, no people, no text, no frame.`;
183
+ const picture = await musePaint(memo, { label: `icon:${item.key}:paint`, prompt, take });
184
+ return samCut(memo, `icon:${item.key}`, picture, item.name);
185
+ }