@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,29 @@
1
+ // A mask is one byte per pixel (0 or 1) at the size of the image it belongs to.
2
+ import sharp from 'sharp';
3
+
4
+ export type Mask = { width: number; height: number; bits: Uint8Array };
5
+
6
+ export const emptyMask = (width: number, height: number): Mask => ({ width, height, bits: new Uint8Array(width * height) });
7
+
8
+ export const maskArea = (m: Mask) => m.bits.reduce((sum, b) => sum + b, 0);
9
+
10
+ /** Writes a mask as an 8-bit grayscale PNG (0 / 255). */
11
+ export function maskToPng(m: Mask): Promise<Buffer> {
12
+ const gray = Buffer.alloc(m.bits.length);
13
+ for (let i = 0; i < gray.length; i++) gray[i] = m.bits[i] ? 255 : 0;
14
+ return sharp(gray, { raw: { width: m.width, height: m.height, channels: 1 } }).png().toBuffer();
15
+ }
16
+
17
+ /** Reads any image as a mask: a pixel is set when its gray value is above the midpoint. */
18
+ export async function maskFromPng(png: Buffer): Promise<Mask> {
19
+ const { data, info } = await sharp(png).grayscale().raw().toBuffer({ resolveWithObject: true });
20
+ const bits = new Uint8Array(info.width * info.height);
21
+ for (let i = 0; i < bits.length; i++) bits[i] = data[i * info.channels] > 127 ? 1 : 0;
22
+ return { width: info.width, height: info.height, bits };
23
+ }
24
+
25
+ export async function imageSize(image: Buffer): Promise<{ width: number; height: number }> {
26
+ const { width, height } = await sharp(image).metadata();
27
+ if (!width || !height) throw new Error('unreadable image');
28
+ return { width, height };
29
+ }
@@ -0,0 +1,38 @@
1
+ // Review pictures: masks tinted over the painting, so a person sees at a glance what a step found.
2
+ import sharp from 'sharp';
3
+ import type { Mask } from './mask.ts';
4
+
5
+ export type Tint = { mask: Mask; rgb: [number, number, number]; alpha?: number };
6
+
7
+ /** Distinct, readable colours for up to a dozen layers; they repeat after that. */
8
+ export const PALETTE: [number, number, number][] = [
9
+ [255, 70, 70], [70, 200, 255], [255, 210, 40], [170, 90, 255], [60, 230, 120], [255, 130, 200],
10
+ [255, 140, 30], [40, 120, 255], [200, 255, 60], [0, 200, 180], [230, 60, 160], [150, 150, 255],
11
+ ];
12
+
13
+ /** Tints each mask over the painting; a mask's edge pixels are drawn solid. */
14
+ export async function tintOver(painting: Buffer, tints: Tint[], extraSvg = ''): Promise<Buffer> {
15
+ const { data, info } = await sharp(painting).removeAlpha().raw().toBuffer({ resolveWithObject: true });
16
+ const { width: W, height: H } = info;
17
+ const out = Buffer.from(data);
18
+ for (const { mask, rgb, alpha = 0.45 } of tints) {
19
+ for (let y = 0; y < H; y++) {
20
+ for (let x = 0; x < W; x++) {
21
+ const i = y * W + x;
22
+ if (!mask.bits[i]) continue;
23
+ const edge = x === 0 || y === 0 || x === W - 1 || y === H - 1 || !mask.bits[i - 1] || !mask.bits[i + 1] || !mask.bits[i - W] || !mask.bits[i + W];
24
+ const a = edge ? 1 : alpha;
25
+ for (let c = 0; c < 3; c++) out[i * 3 + c] = Math.round(out[i * 3 + c] * (1 - a) + rgb[c] * a);
26
+ }
27
+ }
28
+ }
29
+ const img = sharp(out, { raw: { width: W, height: H, channels: 3 } });
30
+ const svg = extraSvg && `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">${extraSvg}</svg>`;
31
+ return (svg ? img.composite([{ input: Buffer.from(svg) }]) : img).png().toBuffer();
32
+ }
33
+
34
+ /** Labels for an overlay, drawn with a dark outline so they read on any painting. */
35
+ export function svgLabel(x: number, y: number, text: string, rgb: [number, number, number] = [255, 255, 255]): string {
36
+ const safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;');
37
+ return `<text x="${x}" y="${y}" font-family="sans-serif" font-size="15" font-weight="bold" fill="rgb(${rgb.join(',')})" stroke="#000" stroke-width="3" paint-order="stroke">${safe}</text>`;
38
+ }
@@ -0,0 +1,224 @@
1
+ // Pixel arithmetic on masks: boxes, overlaps, blobs, polygons. Pure code, no images on disk.
2
+ import { emptyMask, maskArea, type Mask } from './mask.ts';
3
+
4
+ /** [x0, y0, x1, y1] in pixels; x1 and y1 are exclusive. */
5
+ export type Rect = [number, number, number, number];
6
+
7
+ export const rectArea = ([x0, y0, x1, y1]: Rect) => Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
8
+
9
+ export function rectIou(a: Rect, b: Rect): number {
10
+ const inter = rectArea([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
11
+ const union = rectArea(a) + rectArea(b) - inter;
12
+ return union > 0 ? inter / union : 0;
13
+ }
14
+
15
+ /** Sorted corners, clamped to the frame and rounded to whole pixels. */
16
+ export function clampRect([x0, y0, x1, y1]: Rect, width: number, height: number): Rect {
17
+ const cx = (v: number) => Math.round(Math.max(0, Math.min(width, v)));
18
+ const cy = (v: number) => Math.round(Math.max(0, Math.min(height, v)));
19
+ return [cx(Math.min(x0, x1)), cy(Math.min(y0, y1)), cx(Math.max(x0, x1)), cy(Math.max(y0, y1))];
20
+ }
21
+
22
+ export function growRect([x0, y0, x1, y1]: Rect, by: number): Rect {
23
+ const dx = ((x1 - x0) * (by - 1)) / 2;
24
+ const dy = ((y1 - y0) * (by - 1)) / 2;
25
+ return [x0 - dx, y0 - dy, x1 + dx, y1 + dy];
26
+ }
27
+
28
+ /** The smallest rect around the set pixels, or undefined for an empty mask. */
29
+ export function maskBox(m: Mask): Rect | undefined {
30
+ let x0 = m.width, y0 = m.height, x1 = -1, y1 = -1;
31
+ for (let y = 0; y < m.height; y++) {
32
+ for (let x = 0; x < m.width; x++) {
33
+ if (!m.bits[y * m.width + x]) continue;
34
+ if (x < x0) x0 = x;
35
+ if (x > x1) x1 = x;
36
+ if (y < y0) y0 = y;
37
+ y1 = y;
38
+ }
39
+ }
40
+ return x1 < 0 ? undefined : [x0, y0, x1 + 1, y1 + 1];
41
+ }
42
+
43
+ export function rectMask(width: number, height: number, rect: Rect): Mask {
44
+ const m = emptyMask(width, height);
45
+ const [x0, y0, x1, y1] = clampRect(rect, width, height);
46
+ for (let y = y0; y < y1; y++) m.bits.fill(1, y * width + x0, y * width + x1);
47
+ return m;
48
+ }
49
+
50
+ /** Even-odd scanline fill of a polygon given as [x, y] points. */
51
+ export function polygonMask(width: number, height: number, points: [number, number][]): Mask {
52
+ const m = emptyMask(width, height);
53
+ for (let y = 0; y < height; y++) {
54
+ const cy = y + 0.5;
55
+ const xs: number[] = [];
56
+ for (let i = 0; i < points.length; i++) {
57
+ const [ax, ay] = points[i];
58
+ const [bx, by] = points[(i + 1) % points.length];
59
+ if ((ay <= cy) !== (by <= cy)) xs.push(ax + ((cy - ay) / (by - ay)) * (bx - ax));
60
+ }
61
+ xs.sort((a, b) => a - b);
62
+ for (let i = 0; i + 1 < xs.length; i += 2) {
63
+ const from = Math.max(0, Math.ceil(xs[i] - 0.5));
64
+ const to = Math.min(width, Math.ceil(xs[i + 1] - 0.5));
65
+ if (to > from) m.bits.fill(1, y * width + from, y * width + to);
66
+ }
67
+ }
68
+ return m;
69
+ }
70
+
71
+ function combine(a: Mask, b: Mask, op: (x: number, y: number) => number): Mask {
72
+ if (a.width !== b.width || a.height !== b.height) throw new Error(`mask sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`);
73
+ const out = emptyMask(a.width, a.height);
74
+ for (let i = 0; i < out.bits.length; i++) out.bits[i] = op(a.bits[i], b.bits[i]);
75
+ return out;
76
+ }
77
+
78
+ export const union = (a: Mask, b: Mask) => combine(a, b, (x, y) => x | y);
79
+ export const intersect = (a: Mask, b: Mask) => combine(a, b, (x, y) => x & y);
80
+ export const subtract = (a: Mask, b: Mask) => combine(a, b, (x, y) => x & (1 - y));
81
+
82
+ export function unionAll(width: number, height: number, masks: Mask[]): Mask {
83
+ return masks.reduce(union, emptyMask(width, height));
84
+ }
85
+
86
+ export function maskIou(a: Mask, b: Mask): number {
87
+ let inter = 0, either = 0;
88
+ for (let i = 0; i < a.bits.length; i++) {
89
+ inter += a.bits[i] & b.bits[i];
90
+ either += a.bits[i] | b.bits[i];
91
+ }
92
+ return either > 0 ? inter / either : 0;
93
+ }
94
+
95
+ /** Grows (r > 0) or shrinks (r < 0) a mask by a square of |r| px, as two separable passes of running counts. */
96
+ export function dilate(m: Mask, r: number): Mask {
97
+ if (r === 0) return { ...m, bits: m.bits.slice() };
98
+ const grow = r > 0;
99
+ const k = Math.abs(r);
100
+ const { width: W, height: H } = m;
101
+ // One line at a time (a row, or a column with stride W): a pixel is set when any pixel within k is (growing),
102
+ // or when all 2k+1 are, inside the frame (shrinking: outside the frame counts as empty, so it eats in from the edges).
103
+ const pass = (src: Uint8Array, along: 'x' | 'y') => {
104
+ const out = new Uint8Array(src.length);
105
+ const [lines, len, step, lineStep] = along === 'x' ? [H, W, 1, W] : [W, H, W, 1];
106
+ const sum = new Int32Array(len + 1);
107
+ for (let l = 0; l < lines; l++) {
108
+ const base = l * lineStep;
109
+ for (let i = 0; i < len; i++) sum[i + 1] = sum[i] + src[base + i * step];
110
+ for (let i = 0; i < len; i++) {
111
+ const lo = i - k;
112
+ const hi = i + k;
113
+ const n = sum[Math.min(len, hi + 1)] - sum[Math.max(0, lo)];
114
+ out[base + i * step] = grow ? (n > 0 ? 1 : 0) : lo >= 0 && hi < len && n === 2 * k + 1 ? 1 : 0;
115
+ }
116
+ }
117
+ return out;
118
+ };
119
+ return { width: W, height: H, bits: pass(pass(m.bits, 'x'), 'y') };
120
+ }
121
+
122
+ /** 4-connected blobs, largest first. */
123
+ export function components(m: Mask): Mask[] {
124
+ const { width: W, height: H } = m;
125
+ const label = new Int32Array(W * H).fill(-1);
126
+ const blobs: number[][] = [];
127
+ const stack: number[] = [];
128
+ for (let start = 0; start < m.bits.length; start++) {
129
+ if (!m.bits[start] || label[start] >= 0) continue;
130
+ const pixels: number[] = [];
131
+ label[start] = blobs.length;
132
+ stack.push(start);
133
+ while (stack.length) {
134
+ const i = stack.pop()!;
135
+ pixels.push(i);
136
+ const x = i % W;
137
+ for (const n of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
138
+ if (n >= 0 && n < W * H && m.bits[n] && label[n] < 0) {
139
+ label[n] = blobs.length;
140
+ stack.push(n);
141
+ }
142
+ }
143
+ }
144
+ blobs.push(pixels);
145
+ }
146
+ return blobs.sort((a, b) => b.length - a.length).map((pixels) => {
147
+ const blob = emptyMask(W, H);
148
+ for (const i of pixels) blob.bits[i] = 1;
149
+ return blob;
150
+ });
151
+ }
152
+
153
+ /** Drops blobs smaller than `minPx`. */
154
+ export function withoutSpecks(m: Mask, minPx: number): Mask {
155
+ return unionAll(m.width, m.height, components(m).filter((c) => maskArea(c) >= minPx));
156
+ }
157
+
158
+ /** For each column of the mask, its lowest set row (-1 where the column is empty). */
159
+ export function bottomRows(m: Mask): Int32Array {
160
+ const rows = new Int32Array(m.width).fill(-1);
161
+ for (let y = 0; y < m.height; y++) {
162
+ for (let x = 0; x < m.width; x++) if (m.bits[y * m.width + x]) rows[x] = y;
163
+ }
164
+ return rows;
165
+ }
166
+
167
+ /**
168
+ * Fills the holes of a mask: unset regions it closes on every side (they never reach the frame's edge) of at most
169
+ * `maxPx` each. Bigger holes are real gaps (the floor around a wide table), smaller ones are what lies on the surface.
170
+ */
171
+ export function fillHoles(m: Mask, maxPx = Infinity): Mask {
172
+ const { width: W, height: H } = m;
173
+ const bits = m.bits.slice();
174
+ const seen = new Uint8Array(W * H);
175
+ const stack: number[] = [];
176
+ for (let start = 0; start < bits.length; start++) {
177
+ if (bits[start] || seen[start]) continue;
178
+ const pixels: number[] = [];
179
+ let open = false;
180
+ seen[start] = 1;
181
+ stack.push(start);
182
+ while (stack.length) {
183
+ const i = stack.pop()!;
184
+ pixels.push(i);
185
+ const x = i % W;
186
+ const y = (i - x) / W;
187
+ if (x === 0 || y === 0 || x === W - 1 || y === H - 1) open = true;
188
+ for (const n of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, y > 0 ? i - W : -1, y < H - 1 ? i + W : -1]) {
189
+ if (n >= 0 && !bits[n] && !seen[n]) {
190
+ seen[n] = 1;
191
+ stack.push(n);
192
+ }
193
+ }
194
+ }
195
+ if (!open && pixels.length <= maxPx) for (const i of pixels) bits[i] = 1;
196
+ }
197
+ return { width: W, height: H, bits };
198
+ }
199
+
200
+ /**
201
+ * What a frame holds: the opening of a doorway, an arch or a gate, whose posts stand at the sides of its box and whose
202
+ * lintel spans the top. Row by row below the lintel, the gap between the inner edge of the left post and that of the
203
+ * right one; a post SAM cut short keeps its last edge down to the box's bottom, where the floor runs through.
204
+ */
205
+ export function framedOpening(frame: Mask, box: Rect): Mask {
206
+ const { width: W, height: H } = frame;
207
+ const [x0, y0, x1, y1] = clampRect(box, W, H);
208
+ const out = emptyMask(W, H);
209
+ const third = Math.max(1, Math.floor((x1 - x0) / 3));
210
+ const mid = [x0 + third, x1 - third];
211
+ // The lintel: the lowest frame row in the top half of the box's middle third.
212
+ let top = -1;
213
+ for (let y = y0; y < y0 + (y1 - y0) / 2; y++) for (let x = mid[0]; x < mid[1]; x++) if (frame.bits[y * W + x]) top = y;
214
+ if (top < 0) return out;
215
+ let left = -1;
216
+ let right = -1;
217
+ for (let y = top + 1; y < y1; y++) {
218
+ for (let x = mid[0] - 1; x >= x0; x--) if (frame.bits[y * W + x]) { left = x; break; }
219
+ for (let x = mid[1]; x < x1; x++) if (frame.bits[y * W + x]) { right = x; break; }
220
+ if (left < 0 || right < 0) continue;
221
+ for (let x = left + 1; x < right; x++) if (!frame.bits[y * W + x]) out.bits[y * W + x] = 1;
222
+ }
223
+ return out;
224
+ }
@@ -0,0 +1,28 @@
1
+ import './commands/index.ts';
2
+ import { registry } from './registry.ts';
3
+ import { loadSecrets, redact } from './secrets.ts';
4
+
5
+ function help() {
6
+ console.log('diorama: a story prompt becomes painted scenes you can walk in\n\nusage: diorama <command> [args]\n');
7
+ for (const cmd of registry.values()) console.log(` ${cmd.usage}`);
8
+ console.log('\nfirst time: diorama setup (keys, what is missing), then diorama studio. Worlds are written to ./worlds here.');
9
+ }
10
+
11
+ const [name, ...args] = process.argv.slice(2);
12
+ const cmd = name ? registry.get(name) : undefined;
13
+ if (!cmd) {
14
+ help();
15
+ process.exit(!name || name === 'help' ? 0 : 2);
16
+ }
17
+ if (args.includes('--help') || args.includes('-h')) {
18
+ console.log(`usage: diorama ${cmd.usage}`);
19
+ process.exit(0);
20
+ }
21
+ try {
22
+ // Keys kept by `diorama setup` fill in what the environment leaves unset.
23
+ if (name !== 'setup') await loadSecrets();
24
+ await cmd.run(args);
25
+ } catch (error) {
26
+ console.error(redact(`diorama ${name}: ${error instanceof Error ? error.message : error}`));
27
+ process.exit(1);
28
+ }
@@ -0,0 +1,27 @@
1
+ // Which models make a world, as picked in Studio Setup (~/.config/diorama/models.json). DIORAMA_SPARK_VIA and
2
+ // DIORAMA_IMAGE_VIA, where the Studio or a command was started, win over the pick.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { configDir } from '../secrets.ts';
6
+
7
+ export type ModelChoice = { spark?: string; image?: string };
8
+
9
+ const file = () => path.join(configDir(), 'models.json');
10
+
11
+ export function readModelChoice(): ModelChoice {
12
+ try {
13
+ const c = JSON.parse(fs.readFileSync(file(), 'utf8')) as ModelChoice;
14
+ return { spark: typeof c.spark === 'string' ? c.spark : undefined, image: typeof c.image === 'string' ? c.image : undefined };
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ export function writeModelChoice(choice: ModelChoice) {
21
+ fs.mkdirSync(path.dirname(file()), { recursive: true, mode: 0o700 });
22
+ fs.writeFileSync(file(), `${JSON.stringify(choice, null, 2)}\n`);
23
+ }
24
+
25
+ /** The model for Spark's asks and for pictures: the environment first, then the Setup pick. */
26
+ export const sparkChoice = () => process.env.DIORAMA_SPARK_VIA || readModelChoice().spark;
27
+ export const imageChoice = () => process.env.DIORAMA_IMAGE_VIA || readModelChoice().image;
@@ -0,0 +1,99 @@
1
+ // The Meta Model API: one key, a POST that retries what is worth retrying.
2
+ export const META_API = 'https://api.meta.ai/v1';
3
+
4
+ /** The first of these that is set holds the Meta Model API key. */
5
+ export const KEY_VARS = ['META_API_KEY', 'META_MUSE_API_KEY', 'META_MODEL_API_KEY'] as const;
6
+
7
+ export function apiKey(): string {
8
+ const key = KEY_VARS.map((name) => process.env[name]).find(Boolean);
9
+ if (!key) throw new Error(`no Meta Model API key: set one of ${KEY_VARS.join(', ')}`);
10
+ return key;
11
+ }
12
+
13
+ export class MetaHttpError extends Error {
14
+ constructor(readonly status: number, message: string) {
15
+ super(message);
16
+ }
17
+ }
18
+
19
+ const TRANSIENT = new Set([408, 409, 429, 500, 502, 503, 504]);
20
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
21
+ /** Tries for a request whose connection dropped: 2+4+8+16+30+30+30+30 s of waiting. */
22
+ const OFFLINE_TRIES = 9;
23
+
24
+ /**
25
+ * How long one try may take before it is given up and tried again. Without a limit a stalled request waits for the
26
+ * HTTP client's own 300 s: on the Mac one backdrop edit took 343 s where it takes 10 to 20. Muse paints in 10-25 s,
27
+ * SAM answers in 1-6 s, Spark thinks for up to ~100 s.
28
+ */
29
+ function patience(route: string, body: unknown): number {
30
+ // An edit from several pictures (a place painted from its sketch, concept and style) takes longer, more so with
31
+ // a few painting at once: 90 s cut every one of 8 such paints off three times, 270-300 s each where one patient
32
+ // try takes 40-120 s (lantern-stranger, lost-friends-maze). A dropped try is asked again from the back of the queue.
33
+ if (route.startsWith('/images/')) return ((body as { images?: unknown[] } | null)?.images?.length ?? 0) > 1 ? 180_000 : 90_000;
34
+ const model = (body as { model?: string } | null)?.model ?? '';
35
+ return model.startsWith('sam') ? 60_000 : 240_000;
36
+ }
37
+
38
+ /**
39
+ * How many requests each model may have open at once. The build runs places, cast and sound side by side
40
+ * (src/world/schedule.ts), so without a cap a four-place world can ask SAM for dozens of cut-outs at the same
41
+ * moment. A request over the cap waits its turn; DIORAMA_LANES="muse=6,spark=12,sam=16" sets them.
42
+ */
43
+ // Muse at 6: eight multi-picture paints at once queued past every timeout on the Meta side.
44
+ const LANES: Record<string, number> = { muse: 6, spark: 12, sam: 16 };
45
+ for (const pair of (process.env.DIORAMA_LANES ?? '').split(',')) {
46
+ const [lane, n] = pair.split('=');
47
+ if (lane in LANES && Number(n) >= 1) LANES[lane] = Math.floor(Number(n));
48
+ }
49
+ const open: Record<string, number> = {};
50
+ const waiting: Record<string, Array<() => void>> = {};
51
+ const laneOf = (route: string, body: unknown) => (route.startsWith('/images/') ? 'muse' : ((body as { model?: string } | null)?.model ?? '').startsWith('sam') ? 'sam' : 'spark');
52
+
53
+ async function inLane<T>(lane: string, work: () => Promise<T>): Promise<T> {
54
+ if ((open[lane] ?? 0) >= LANES[lane]) await new Promise<void>((go) => (waiting[lane] ??= []).push(go));
55
+ else open[lane] = (open[lane] ?? 0) + 1;
56
+ try {
57
+ return await work();
58
+ } finally {
59
+ // The slot passes straight to the next in line, or is given back.
60
+ const next = waiting[lane]?.shift();
61
+ if (next) next();
62
+ else open[lane]--;
63
+ }
64
+ }
65
+
66
+ /** POSTs JSON. Rate limits, server errors and stalls are retried with backoff (2, 4, 8 s); network drops for longer. */
67
+ export async function metaPost(route: string, body: unknown, accept?: string): Promise<Response> {
68
+ const tries = 4;
69
+ const limit = patience(route, body);
70
+ for (let n = 1; ; n++) {
71
+ let failure: unknown;
72
+ try {
73
+ // The slot is held until the answer is read or turned down, not through the wait before a retry.
74
+ const res = await inLane(laneOf(route, body), async () => {
75
+ const res = await fetch(META_API + route, {
76
+ signal: AbortSignal.timeout(limit),
77
+ method: 'POST',
78
+ headers: { Authorization: `Bearer ${apiKey()}`, 'Content-Type': 'application/json', ...(accept && { Accept: accept }) },
79
+ body: JSON.stringify(body),
80
+ });
81
+ return res.ok ? res : new MetaHttpError(res.status, `POST ${route}: ${res.status} ${(await res.text()).slice(0, 300)}`);
82
+ });
83
+ if (!(res instanceof MetaHttpError)) return res;
84
+ failure = res;
85
+ if (!TRANSIENT.has(res.status)) throw failure;
86
+ } catch (error) {
87
+ if (error instanceof MetaHttpError && !TRANSIENT.has(error.status)) throw error;
88
+ failure = error;
89
+ }
90
+ // A dropped connection (Wi-Fi gone, the laptop dozing: "fetch failed") gets longer: up to ~3 min in all, so a
91
+ // short outage does not cost a place its painting.
92
+ const offline = failure instanceof TypeError || (failure instanceof Error && /fetch failed|ECONNRESET|ENOTFOUND|EAI_AGAIN|socket/i.test(failure.message));
93
+ if (n >= (offline ? OFFLINE_TRIES : tries)) throw failure;
94
+ // Said in the job log, so a slow step shows why.
95
+ const why = failure instanceof Error && failure.name === 'TimeoutError' ? `no answer in ${limit / 1000} s` : failure instanceof Error ? failure.message.slice(0, 120) : String(failure);
96
+ console.log(` POST ${route}: ${why}; trying again (${n + 1}/${tries})`);
97
+ await sleep(Math.min(30_000, 1000 * 2 ** n));
98
+ }
99
+ }
@@ -0,0 +1,91 @@
1
+ // Model answers are stored by a hash of everything that went in, so a rerun with the same
2
+ // inputs costs nothing, and any change to a prompt or image asks the model again.
3
+ import { createHash } from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { trackOperation } from '../studio/operation.ts';
8
+ import { currentStep } from './step.ts';
9
+
10
+ /** sound: ElevenLabs effects and music; music: MusicGen run locally; voice: speech and its Muse Voice transcript. */
11
+ /** space: a Hugging Face Space (TRELLIS, auto-rig), not a Meta model; its answers are kept the same way. */
12
+ export type ModelName = 'spark' | 'muse' | 'sam' | 'sound' | 'music' | 'voice' | 'space';
13
+
14
+ const SHARED = new Set<ModelName>(['sound', 'music', 'voice']);
15
+ const sharedFile = (model: ModelName, hash: string, ext: string) => path.join(process.env.DIORAMA_CACHE || path.join(os.homedir(), '.cache', 'diorama'), 'memo', model, `${hash}.${ext}`);
16
+
17
+ export function inputHash(...parts: (string | Buffer)[]): string {
18
+ const h = createHash('sha256');
19
+ for (const part of parts) h.update(part).update('\u0000');
20
+ return h.digest('hex').slice(0, 32);
21
+ }
22
+
23
+ /** What a call cost, when the asker knows: dollars and which backend answered (e.g. "claude" for a dev-mode Spark). */
24
+ export type Paid = { data: Buffer; usd?: number; via?: string };
25
+
26
+ export class Memo {
27
+ /** Calls actually sent to each model in this process (cache hits are not counted). */
28
+ readonly sent: Record<ModelName, number> = { spark: 0, muse: 0, sam: 0, sound: 0, music: 0, voice: 0, space: 0 };
29
+
30
+ constructor(readonly dir: string) {}
31
+
32
+ /** Asks on their way: the same question asked twice at once (two exits with one sound) is paid once. */
33
+ private readonly asking = new Map<string, Promise<Buffer>>();
34
+
35
+ async remember(model: ModelName, label: string, hash: string, ext: string, ask: () => Promise<Buffer | Paid>): Promise<Buffer> {
36
+ const id = `${model}/${hash}.${ext}`;
37
+ const pending = this.asking.get(id);
38
+ if (pending) {
39
+ const answer = await pending;
40
+ this.log({ model, label, hash, ms: 0, hit: true });
41
+ return answer;
42
+ }
43
+ const work = this.recall(model, label, hash, ext, ask);
44
+ this.asking.set(id, work);
45
+ try {
46
+ return await work;
47
+ } finally {
48
+ this.asking.delete(id);
49
+ }
50
+ }
51
+
52
+ private async recall(model: ModelName, label: string, hash: string, ext: string, ask: () => Promise<Buffer | Paid>): Promise<Buffer> {
53
+ const file = path.join(this.dir, model, `${hash}.${ext}`);
54
+ // Paid audio is also kept for every world on this machine: another world asking the same pays nothing.
55
+ const shared = SHARED.has(model) ? sharedFile(model, hash, ext) : undefined;
56
+ for (const f of [file, shared]) {
57
+ if (!f || !fs.existsSync(f)) continue;
58
+ this.log({ model, label, hash, ms: 0, hit: true });
59
+ const answer = fs.readFileSync(f);
60
+ if (f !== file) (fs.mkdirSync(path.dirname(file), { recursive: true }), fs.writeFileSync(file, answer));
61
+ return answer;
62
+ }
63
+ const started = Date.now();
64
+ const got = await trackOperation(model, label, ask);
65
+ const { data: answer, usd, via } = Buffer.isBuffer(got) ? { data: got } as Paid : got;
66
+ for (const f of [file, shared]) if (f) (fs.mkdirSync(path.dirname(f), { recursive: true }), fs.writeFileSync(f, answer));
67
+ this.sent[model]++;
68
+ this.log({ model, label, hash, ms: Date.now() - started, hit: false, ...(usd !== undefined && { usd: Math.round(usd * 1e6) / 1e6 }), ...(via && { via }) });
69
+ return answer;
70
+ }
71
+
72
+ /** Whether an answer is kept, here or in the cache every world shares. */
73
+ has(model: ModelName, hash: string, ext: string): boolean {
74
+ return fs.existsSync(path.join(this.dir, model, `${hash}.${ext}`)) || (SHARED.has(model) && fs.existsSync(sharedFile(model, hash, ext)));
75
+ }
76
+
77
+ /**
78
+ * Forgets one answer, e.g. a reply that failed its gate and must be asked again. Also the copy every world shares:
79
+ * left there, the next run would read the rejected take back from it and never ask again.
80
+ */
81
+ forget(model: ModelName, hash: string, ext: string) {
82
+ fs.rmSync(path.join(this.dir, model, `${hash}.${ext}`), { force: true });
83
+ if (SHARED.has(model)) fs.rmSync(sharedFile(model, hash, ext), { force: true });
84
+ }
85
+
86
+ private log(entry: { model: ModelName; label: string; hash: string; ms: number; hit: boolean; usd?: number; via?: string }) {
87
+ fs.mkdirSync(this.dir, { recursive: true });
88
+ const step = currentStep();
89
+ fs.appendFileSync(path.join(this.dir, 'calls.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...entry, ...(step && { step }) })}\n`);
90
+ }
91
+ }
@@ -0,0 +1,70 @@
1
+ // Muse Image: paints from a prompt, or from reference images whose layout it keeps.
2
+ import { metaPost } from './http.ts';
3
+ import { inputHash, type Memo } from './memo.ts';
4
+ import { imageChoice } from './choice.ts';
5
+ import { IMAGE_MODELS, paint } from './openrouter.ts';
6
+ import { PRICES } from './prices.ts';
7
+
8
+ export const MUSE = 'muse-image-1.0';
9
+
10
+ /**
11
+ * Which model paints (Studio Setup, or DIORAMA_IMAGE_VIA): "meta" (Muse Image, the default), a short name from IMAGE_MODELS
12
+ * (gemini-flash, gemini-lite, nano-banana, gemini-pro) or any OpenRouter image model id. Another model's
13
+ * pictures are kept under their own memo keys, so switching back finds the Muse ones again.
14
+ */
15
+ export function imageVia(): string | null {
16
+ const via = imageChoice();
17
+ return !via || via === 'meta' ? null : (IMAGE_MODELS[via] ?? via);
18
+ }
19
+
20
+ type MuseAsk = { label: string; prompt: string; size?: string; /** Changes only the memo key, to get a new take. */ take?: number };
21
+
22
+ /** The picture behind a URL answer, its download retried like the request was (a dropped connection, a 5xx). */
23
+ async function fetchImage(url: string): Promise<Buffer> {
24
+ for (let n = 1; ; n++) {
25
+ try {
26
+ const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
27
+ if (res.ok) return Buffer.from(await res.arrayBuffer());
28
+ if (res.status < 500 || n >= 6) throw new Error(`image download: ${res.status}`);
29
+ } catch (error) {
30
+ if (n >= 6 || (error instanceof Error && error.message.startsWith('image download:'))) throw error;
31
+ }
32
+ await new Promise((r) => setTimeout(r, Math.min(30_000, 1000 * 2 ** n)));
33
+ }
34
+ }
35
+
36
+ async function firstImage(res: Response, label: string): Promise<Buffer> {
37
+ const body = (await res.json()) as { data?: { b64_json?: string; url?: string }[] };
38
+ const item = body.data?.[0];
39
+ if (item?.b64_json) return Buffer.from(item.b64_json, 'base64');
40
+ if (item?.url) return fetchImage(item.url);
41
+ throw new Error(`muse ${label}: no image in the answer`);
42
+ }
43
+
44
+ // Without output_format the API answers WebP; everything downstream reads PNG.
45
+ const PNG_OUT = { n: 1, response_format: 'b64_json', output_format: 'png' } as const;
46
+
47
+ export async function musePaint(memo: Memo, { label, prompt, size = '1200x896', take = 0 }: MuseAsk): Promise<Buffer> {
48
+ const via = imageVia();
49
+ if (via) return memo.remember('muse', label, inputHash(via, 'paint', size, String(take), prompt), 'png', () => paint(label, via, prompt, size));
50
+ return memo.remember('muse', label, inputHash(MUSE, 'paint', size, String(take), prompt), 'png', async () =>
51
+ ({ data: await firstImage(await metaPost('/images/generations', { model: MUSE, prompt, size, ...PNG_OUT }), label), usd: PRICES.museImage }));
52
+ }
53
+
54
+ export async function museEdit(memo: Memo, { label, prompt, size = '1200x896', take = 0, images }: MuseAsk & { images: Buffer[] }): Promise<Buffer> {
55
+ if (images.length === 0) throw new Error(`muse ${label}: an edit needs an image`);
56
+ const via = imageVia();
57
+ if (via) return memo.remember('muse', label, inputHash(via, 'edit', size, String(take), prompt, ...images), 'png', () => paint(label, via, prompt, size, images));
58
+ return memo.remember('muse', label, inputHash(MUSE, 'edit', size, String(take), prompt, ...images), 'png', async () => ({
59
+ usd: PRICES.museImage,
60
+ data: await firstImage(await metaPost('/images/edits', {
61
+ model: MUSE,
62
+ prompt,
63
+ size,
64
+ images: images.map((image) => ({ image_url: `data:image/png;base64,${image.toString('base64')}` })),
65
+ reasoning_strength: 'low',
66
+ tool_enablement: { enable_image_search: false, enable_web_search: false, enable_shell: false },
67
+ ...PNG_OUT,
68
+ }), label),
69
+ }));
70
+ }