@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,109 @@
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 sharp from 'sharp';
6
+ import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
7
+ import { components, dilate, maskBox, subtract, union } from '../image/raster.ts';
8
+ import { fillHoles } from './warp.ts';
9
+ import type { Memo } from '../meta/memo.ts';
10
+ import { samFind } from '../meta/sam.ts';
11
+ import { askSparkJson } from '../meta/spark.ts';
12
+
13
+ /** Words that say the description has something in hand; without one no Spark call is made. */
14
+ 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;
15
+
16
+ /**
17
+ * The things the character holds, carries or lifts, as short phrases SAM can search ("slice of toast", "lantern").
18
+ * `holds` (cast/fixes.json, set by the person) wins; otherwise one memoized Spark call on the description, and none
19
+ * at all when it says nothing about holding. Clothing and what is worn is never in the list: SAM's person has it.
20
+ */
21
+ export async function heldThings(memo: Memo, who: { key: string; looks: string; holds?: string[] }): Promise<string[]> {
22
+ if (who.holds) return who.holds.map((h) => h.trim()).filter(Boolean);
23
+ if (!HOLDING.test(who.looks)) return [];
24
+ const a = await askSparkJson<{ held?: unknown }>(memo, {
25
+ label: `figure-held:${who.key}`,
26
+ 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": ["..."]}`,
27
+ });
28
+ return Array.isArray(a.held) ? a.held.filter((h): h is string => typeof h === 'string' && !!h.trim()).map((h) => h.trim()).slice(0, 3) : [];
29
+ }
30
+
31
+ /**
32
+ * Every instance SAM finds of the held things that touches the figure (or comes within a few percent of its height),
33
+ * outside SAM's person, as one mask; undefined when there is none. One or two SAM calls per phrase, memoized with the
34
+ * image; when SAM finds none of them, what stands out from the plain ground and touches the body (plainHeld).
35
+ */
36
+ export async function heldMask(memo: Memo, key: string, image: Buffer, person: Mask, phrases: string[]): Promise<Mask | undefined> {
37
+ const box = maskBox(person);
38
+ if (!box || !phrases.length) return undefined;
39
+ const near = dilate(person, Math.max(3, Math.round(0.04 * (box[3] - box[1]))));
40
+ let out: Mask | undefined;
41
+ for (const phrase of phrases) {
42
+ // "slice of toast" may find nothing where "toast" does: the head noun is asked next.
43
+ const head = /\bof\b/i.test(phrase) ? phrase.split(/\bof\b/i).pop()!.trim() : phrase.trim().split(/\s+/).pop()!;
44
+ for (const prompt of [...new Set([phrase, head])]) {
45
+ let hit = false;
46
+ for (const found of await samFind(memo, { label: `figure:${key}:held`, image, prompt })) {
47
+ const own = subtract(found.mask, person);
48
+ if (maskArea(own) < 0.01 * maskArea(person)) continue;
49
+ if (!own.bits.some((b, i) => b && near.bits[i])) continue;
50
+ out = union(out ?? emptyMask(person.width, person.height), own);
51
+ hit = true;
52
+ }
53
+ if (hit) break;
54
+ }
55
+ }
56
+ if (out) return out;
57
+ const { data } = await sharp(image).resize(person.width, person.height, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
58
+ return plainHeld(data, person);
59
+ }
60
+
61
+ /**
62
+ * Without SAM: the figure is painted alone on a plain ground, so what stands out from that ground and touches the
63
+ * body (not the picture's edge) is what they hold. Used when the description says they hold something SAM did not find.
64
+ */
65
+ export function plainHeld(rgba: Buffer, person: Mask, tolerance = 64): Mask | undefined {
66
+ const { width: W, height: H } = person;
67
+ const box = maskBox(person);
68
+ if (!box) return undefined;
69
+ const [, py0, , py1] = box;
70
+ const ground = groundColour(rgba, W, H);
71
+ const ink = emptyMask(W, H);
72
+ for (let i = 0; i < W * H; i++) ink.bits[i] = !person.bits[i] && Math.hypot(rgba[i * 4] - ground[0], rgba[i * 4 + 1] - ground[1], rgba[i * 4 + 2] - ground[2]) > tolerance ? 1 : 0;
73
+ // The outline drawn round a thing is ink too, so the thing and its outline join into one piece.
74
+ const near = dilate(person, 3);
75
+ let out: Mask | undefined;
76
+ for (const c of components(fillHoles(ink))) {
77
+ const b = maskBox(c)!;
78
+ if (b[0] <= 1 || b[1] <= 1 || b[2] >= W - 1 || b[3] >= H - 1) continue;
79
+ if (maskArea(c) < 0.05 * maskArea(person) || !c.bits.some((v, i) => v && near.bits[i])) continue;
80
+ // A patch of floor or shadow at the feet is not held.
81
+ if ((b[1] + b[3]) / 2 > py0 + 0.85 * (py1 - py0)) continue;
82
+ out = union(out ?? emptyMask(W, H), subtract(c, person));
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** The patches of plain ground (the picture's border colour) inside `whole` that are no part of `body`, bigger than a speck. */
88
+ export function groundGaps(rgba: Buffer, whole: Mask, body: Mask, tolerance = 40): Mask[] {
89
+ const { width: W, height: H } = whole;
90
+ const ground = groundColour(rgba, W, H);
91
+ const gap = emptyMask(W, H);
92
+ for (let i = 0; i < W * H; i++) gap.bits[i] = whole.bits[i] && !body.bits[i] && Math.hypot(rgba[i * 4] - ground[0], rgba[i * 4 + 1] - ground[1], rgba[i * 4 + 2] - ground[2]) <= tolerance ? 1 : 0;
93
+ return components(gap).filter((c) => maskArea(c) > 0.002 * maskArea(body));
94
+ }
95
+
96
+ const groundColour = (rgba: Buffer, W: number, H: number) => {
97
+ const border: number[][] = [];
98
+ for (let x = 0; x < W; x += 4) for (const y of [0, H - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
99
+ for (let y = 0; y < H; y += 4) for (const x of [0, W - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
100
+ return [0, 1, 2].map((c) => border.map((p) => p[c]).sort((a, b) => a - b)[border.length >> 1]);
101
+ };
102
+
103
+ /** 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. */
104
+ export function bodyShare(person: Mask, whole: Mask): number {
105
+ const p = maskBox(person);
106
+ const w = maskBox(whole);
107
+ if (!p || !w) return 1;
108
+ return Math.min(1, Math.round(((p[3] - p[1]) / Math.max(1, w[3] - w[1])) * 1000) / 1000);
109
+ }
@@ -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
+ }