@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,167 @@
1
+ // diorama derive <slug> [place...]: geometry.json (walk grid, scale, occluders, spots) and walk.png per place.
2
+ import fs from 'node:fs';
3
+ import { maskArea, maskFromPng, maskToPng, emptyMask } from '../image/mask.ts';
4
+ import { PALETTE, svgLabel, tintOver } from '../image/overlay.ts';
5
+ import { Geometry, personPx } from '../play/geometry.ts';
6
+ import { defineCommand } from '../registry.ts';
7
+ import { brush, placeNpcs, readCorrections, type Corrections } from '../scene/corrections.ts';
8
+ import { cellsMask } from '../scene/locate.ts';
9
+ import { derive, type ThingIn } from '../scene/derive.ts';
10
+ import { depthMap, uprightTest } from '../scene/depthmap.ts';
11
+ import { paintedGlows } from '../scene/glows.ts';
12
+ import { carryFrom, ran, tilesLine } from '../scene/carry.ts';
13
+ import { Layout, FRAME } from '../scene/layout.ts';
14
+ import { CELL, nearestRoomy, reach, roomy } from '../scene/walk.ts';
15
+ import { closeFaces, placeDepth } from '../scene/occlusion-check.ts';
16
+ import { gridOf } from '../scene/verify.ts';
17
+ import { unionAll } from '../image/raster.ts';
18
+ import { WorldFiles } from '../world/files.ts';
19
+
20
+ type MasksJson = { things: Record<string, Omit<ThingIn, 'key' | 'mask'>> };
21
+
22
+ /** The review picture: walkable cells tinted by floor, footprints red, floor lines, spots and the hero's size. */
23
+ async function walkPicture(painting: Buffer, g: Geometry, footprint: ThingIn['mask']): Promise<Buffer> {
24
+ const floors = Math.max(0, ...g.zone!.flatMap((row) => [...row].filter((ch) => ch !== '.').map(Number))) + 1;
25
+ const cells = Array.from({ length: floors }, () => emptyMask(FRAME.width, FRAME.height));
26
+ g.walk.forEach((row, r) => [...row].forEach((ch, c) => {
27
+ if (ch !== '#') return;
28
+ const m = cells[Number(g.zone![r][c])] ?? cells[0];
29
+ for (let y = r * CELL; y < Math.min(FRAME.height, (r + 1) * CELL); y++) m.bits.fill(1, y * FRAME.width + c * CELL, y * FRAME.width + Math.min(FRAME.width, (c + 1) * CELL));
30
+ }));
31
+ const tints = [...cells.map((mask, i) => ({ mask, rgb: PALETTE[(i + 4) % PALETTE.length], alpha: 0.3 })), { mask: footprint, rgb: [255, 40, 40] as [number, number, number], alpha: 0.55 }];
32
+ const svg: string[] = [];
33
+ for (const o of g.occluders) {
34
+ svg.push(`<polyline points="${(o.floor ?? []).map((p) => p.join(',')).join(' ')}" fill="none" stroke="#ffe600" stroke-width="3"/>`);
35
+ }
36
+ // Each painted thing is named on itself (over the top of its box), and the spot the hero walks to for it is a
37
+ // small dot joined to it by a thin line: names piled up where the hero stands read as things in the wrong place.
38
+ for (const t of g.things) {
39
+ if (t.role === 'npc' || !t.box) {
40
+ svg.push(`<circle cx="${t.stand[0]}" cy="${t.stand[1]}" r="6" fill="#fff" stroke="#000" stroke-width="2"/>`, svgLabel(t.stand[0] + 9, t.stand[1] + 5, t.role === 'npc' ? `${t.key} stands here (not painted)` : t.key));
41
+ continue;
42
+ }
43
+ const [x0, y0, x1, y1] = t.box;
44
+ const [cx, cy] = [(x0 + x1) / 2, (y0 + y1) / 2];
45
+ const lx = Math.max(4, Math.min(FRAME.width - 8 * t.key.length - 12, cx - 4 * t.key.length));
46
+ const ly = y0 > 30 ? y0 - 8 : Math.min(FRAME.height - 8, y0 + 22);
47
+ svg.push(
48
+ `<rect x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="2" stroke-dasharray="6 5"/>`,
49
+ `<line x1="${t.stand[0]}" y1="${t.stand[1]}" x2="${cx}" y2="${cy}" stroke="#fff" stroke-opacity="0.7" stroke-width="1.5"/>`,
50
+ `<circle cx="${t.stand[0]}" cy="${t.stand[1]}" r="4" fill="#fff" stroke="#000" stroke-width="1.5"/>`,
51
+ svgLabel(lx, ly, t.key),
52
+ );
53
+ }
54
+ // The hero's size at three depths, as little figures spread over the floor (far left, middle, near right):
55
+ // stacked boxes in one column read as a path, not as a person.
56
+ const rows = g.walk.map((row, r) => (row.includes('#') ? r : -1)).filter((r) => r >= 0);
57
+ for (const [q, across] of [[0.1, 0.2], [0.5, 0.5], [0.95, 0.8]] as const) {
58
+ const r = rows[Math.floor(q * (rows.length - 1))];
59
+ const cols = [...g.walk[r]].flatMap((ch, c) => (ch === '#' ? [c] : []));
60
+ // The walkable cell nearest to a quarter, the middle and three quarters of the frame, so they stand apart.
61
+ const want = (across * FRAME.width) / CELL;
62
+ const c = cols.reduce((best, col) => (Math.abs(col - want) < Math.abs(best - want) ? col : best));
63
+ const [x, y] = [(c + 0.5) * CELL, (r + 0.5) * CELL];
64
+ const h = personPx(g, y);
65
+ const head = h * 0.065;
66
+ svg.unshift(`<g fill="rgba(80,160,255,0.6)" stroke="#fff" stroke-width="2"><circle cx="${x}" cy="${y - h + head}" r="${head}"/><rect x="${x - h * 0.1}" y="${y - h + head * 2.2}" width="${h * 0.2}" height="${h - head * 2.2}" rx="${h * 0.05}"/></g>`);
67
+ if (q === 0.95) svg.push(svgLabel(x - h * 0.1, y - h - 8, 'hero size'));
68
+ }
69
+ // Each step's back edge on stairs, where the feet climb.
70
+ for (const st of g.steps ?? []) svg.push(`<polyline points="${st.top.flatMap((t, i) => (t >= 0 ? [`${st.x0 + i * st.every},${t}`] : [])).join(' ')}" fill="none" stroke="#3cf" stroke-width="3"/>`);
71
+ svg.push(`<circle cx="${g.start[0]}" cy="${g.start[1]}" r="9" fill="#3f6" stroke="#000" stroke-width="2"/>`, svgLabel(g.start[0] + 12, g.start[1] + 5, 'start'));
72
+ svg.push(`<line x1="0" y1="${g.scale.horizonY}" x2="${FRAME.width}" y2="${g.scale.horizonY}" stroke="#5af" stroke-width="2" stroke-dasharray="12 8"/>`, svgLabel(8, g.scale.horizonY - 6, 'horizon'));
73
+ return tintOver(painting, tints, svg.join(''));
74
+ }
75
+
76
+ /**
77
+ * The floor someone said is floor: each floor's strokes that add (the person's, the agent's, the free bridge's)
78
+ * and the cells the person added. Erase strokes after them take back what they cover.
79
+ */
80
+ export function saidFloor(c: Corrections): ThingIn['mask'] | undefined {
81
+ const floors = Object.values(c.floors);
82
+ const strokes = floors.flatMap((f) => f.strokes);
83
+ const cells = floors.flatMap((f) => f.add);
84
+ if (!cells.length && !strokes.some((s) => s.mode === 'add')) return undefined;
85
+ const said = brush(cells.length ? cellsMask(cells) : emptyMask(FRAME.width, FRAME.height), strokes);
86
+ return maskArea(said) ? said : undefined;
87
+ }
88
+
89
+ defineCommand({
90
+ name: 'derive',
91
+ usage: 'derive <slug> [place...] measure walk grid, scale and occluders from the masks (geometry.json)',
92
+ async run([slug, ...only]) {
93
+ if (!slug) throw new Error('usage: diorama derive <slug> [place...]');
94
+ const files = new WorldFiles(slug);
95
+ const world = files.world();
96
+ const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
97
+ await Promise.all(places.map(async (place) => {
98
+ const at = (...parts: string[]) => files.place(place.key, ...parts);
99
+ // NPCs are drawn by the runtime, not painted: moving one only moves its box (and where the hero talks to it).
100
+ const layout = placeNpcs(Layout.parse(files.readJson(at('layout.json'))), new Set(place.things.filter((t) => t.role === 'npc').map((t) => t.key)), readCorrections(at()));
101
+ const made = files.readJson(at('masks.json')) as MasksJson;
102
+ const painting = fs.readFileSync(at('painting.png'));
103
+ const things = await Promise.all(Object.entries(made.things).map(async ([key, t]) => ({ ...t, key, mask: await maskFromPng(fs.readFileSync(at('masks', `${key}.png`))) })));
104
+ const floors = await Promise.all(layout.floors.map(async (f) => maskFromPng(fs.readFileSync(at('masks', 'floors', `${f.key}.png`)))));
105
+ const plates = fs.existsSync(at('plates')) ? fs.readdirSync(at('plates')).filter((f) => f.endsWith('.png')).map((f) => f.slice(0, -4)) : [];
106
+ const carry = await carryFrom(at(), 'derive', painting);
107
+ if (carry) console.log(` ${place.key}: ${tilesLine(carry)}; questions it does not reach keep their answers`);
108
+ const before = fs.existsSync(at('geometry.json')) ? Geometry.safeParse(files.readJson(at('geometry.json'))).data?.scale : undefined;
109
+ const previousScale = before && { ...before, source: before.source ?? ('spark' as const) };
110
+ const lastScale = carry ? previousScale : undefined;
111
+ const start = readCorrections(at()).start;
112
+ const said = saidFloor(readCorrections(at()));
113
+ // The lights as painted: the layout's points are guesses made before the painting (a lighthouse lamp drawn
114
+ // 170 px above its point). The runtime glows sit on them, and the lamp that lights the ground is found by them.
115
+ const holders = things.filter((t) => t.kind !== 'walk-on' && t.kind !== 'flat').map((t) => t.mask);
116
+ const glows = await paintedGlows(painting, layout.lights, holders);
117
+ if (glows.line) console.log(` ${place.key}: ${glows.line}`);
118
+ const asPainted = { ...layout, lights: glows.glows.map((g) => ({ ...layout.lights.find((l) => l.key === g.key)!, x: g.x, y: g.y })) };
119
+ // Upright surfaces from the depth model: no path joining the floors climbs a wall. Without the model, none.
120
+ const upright = await uprightTest(painting, unionAll(FRAME.width, FRAME.height, floors)).catch((e: Error) => {
121
+ console.log(` ${place.key}: no depth model for walls (${e.message})`);
122
+ return undefined;
123
+ });
124
+ const out = await derive(files.memo, { place, layout: asPainted, things, floors, painting, plates, carry, lastScale, previousScale, ...(start && { start }), ...(upright && { upright }), ...(said && { said }) }, (line) => console.log(` ${place.key}: ${line}`));
125
+ // Shadows only with a light: without one the runtime has nothing to darken by.
126
+ const lit = out.shadows.light && maskArea(out.shadows.mask) > 0;
127
+ if (lit) files.write(at('masks', 'shadows.png'), await maskToPng(out.shadows.mask));
128
+ else fs.rmSync(at('masks', 'shadows.png'), { force: true });
129
+ // Where the person said the hero should stand to use a thing.
130
+ const fixes = readCorrections(at());
131
+ const placed = out.geometry.things.map((t) => (fixes.things[t.key]?.stand ? { ...t, stand: fixes.things[t.key].stand! } : t));
132
+ const measured = { ...out.geometry, things: placed, ...(out.shadows.light && { light: out.shadows.light }), ...(lit && { shadows: 'masks/shadows.png' }) };
133
+ // Depth for shadows that follow the ground; without it (no fit, no model) they lie flat as before.
134
+ const depth = await depthMap(painting, measured).catch((e: Error) => ({ reason: e.message }));
135
+ if ('png' in depth) {
136
+ files.write(at('depth.png'), depth.png);
137
+ console.log(` ${place.key}: depth fits the floor on ${Math.round(depth.inliers * 100)}% of ${depth.cells} walk cells`);
138
+ } else {
139
+ fs.rmSync(at('depth.png'), { force: true });
140
+ measured.flagged = [...(measured.flagged ?? []), `depth: ${depth.reason}, shadows stay flat`];
141
+ }
142
+ const geometry = Geometry.parse({ ...measured, ...('png' in depth && { depth: 'depth.png' }), ...(layout.lights.length && { glows: glows.glows }) });
143
+ // Walk cells on an upright face (the front of a bridge's planks) close, with the floor only they led to.
144
+ const read = await placeDepth(at(), geometry);
145
+ const faced = read && closeFaces(geometry, read.d, read.owned);
146
+ if (faced) {
147
+ Object.assign(geometry, { walk: faced.walk, zone: faced.zone, start: faced.start });
148
+ // A stand the closing left out of reach (on the face, or on a fringe too narrow to walk) moves to the nearest
149
+ // floor the start reaches.
150
+ const grid = gridOf(geometry);
151
+ const reached = reach(grid, grid.cellOf(...geometry.start), roomy(grid));
152
+ geometry.things = geometry.things.map((t) => {
153
+ if (reached[grid.cellOf(...t.stand)]) return t;
154
+ const i = nearestRoomy(grid, ...t.stand, reached);
155
+ return i === undefined ? t : { ...t, stand: grid.centre(i) };
156
+ });
157
+ console.log(` ${place.key}: closed ${faced.closed} walk cells on an upright face (feet over thin air)${faced.beyond ? ` and ${faced.beyond} only they led to` : ''}`);
158
+ }
159
+ files.write(at('geometry.json'), geometry);
160
+ files.write(at('walk.png'), await walkPicture(painting, geometry, out.footprint));
161
+ ran(at(), 'derive', carry?.old ?? painting);
162
+ const open = geometry.walk.join('').split('#').length - 1;
163
+ console.log(`${place.key}: ${open} walk cells, ${geometry.occluders.length} occluders, person ${Math.round(personPx(geometry, FRAME.height - 20))} px at the front (${geometry.scale.source})`);
164
+ }));
165
+ console.log(`spark calls sent: ${files.memo.sent.spark}`);
166
+ },
167
+ });
@@ -0,0 +1,74 @@
1
+ // diorama doctor: one live call per model, so a bad key or a changed API shows up before a build.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { maskArea, maskToPng } from '../image/mask.ts';
5
+ import { Memo } from '../meta/memo.ts';
6
+ import { museEdit, musePaint } from '../meta/muse.ts';
7
+ import { samFind } from '../meta/sam.ts';
8
+ import { askSparkJson } from '../meta/spark.ts';
9
+ import { defineCommand } from '../registry.ts';
10
+
11
+ defineCommand({
12
+ name: 'doctor',
13
+ usage: 'doctor [dir] [--now] check the API key with one call to Spark, Muse paint, Muse edit and SAM 3.1; --now skips the memo and says if the network and Meta are fast enough for a live build',
14
+ async run(args) {
15
+ const nowAt = args.indexOf('--now');
16
+ if (nowAt >= 0) args.splice(nowAt, 1);
17
+ const [dir = '.diorama/doctor'] = args;
18
+ const out = path.resolve(dir);
19
+ fs.mkdirSync(out, { recursive: true });
20
+ // A memo hit takes no time, so --now asks afresh to measure this network and Meta as they are.
21
+ const memo = new Memo(path.join(out, nowAt >= 0 ? `memo-${Date.now()}` : 'memo'));
22
+ let failed = 0;
23
+ const took: Record<string, number> = {};
24
+ async function check<T>(name: string, run: () => Promise<T>, describe: (v: T) => string): Promise<T | undefined> {
25
+ const started = Date.now();
26
+ try {
27
+ const value = await run();
28
+ took[name] = Date.now() - started;
29
+ console.log(` ok ${name.padEnd(11)}${`${Date.now() - started} ms`.padStart(9)} ${describe(value)}`);
30
+ return value;
31
+ } catch (error) {
32
+ failed++;
33
+ console.log(` FAIL ${name.padEnd(11)}${error instanceof Error ? error.message : error}`);
34
+ }
35
+ }
36
+
37
+ await check('spark', () => askSparkJson<{ thing: string }>(memo, {
38
+ label: 'doctor',
39
+ prompt: 'Name one thing a traveller might find at a crossroads inn. Reply as JSON: {"thing": "..."}',
40
+ }), (a) => `"${a.thing}"`);
41
+
42
+ const painting = await check('muse paint', () => musePaint(memo, {
43
+ label: 'doctor',
44
+ prompt: 'A painted background for a 2D adventure game: the yard of a crossroads inn at dusk, a cart with a wooden barrel, a well, lanterns on the porch. Gouache, no people, no text.',
45
+ }), (png) => `${Math.round(png.length / 1024)} KB`);
46
+ if (!painting) return void process.exit(1);
47
+ fs.writeFileSync(path.join(out, 'paint.png'), painting);
48
+
49
+ const edited = await check('muse edit', () => museEdit(memo, {
50
+ label: 'doctor',
51
+ images: [painting],
52
+ prompt: 'The same yard on a snowy winter night. Keep the composition exactly.',
53
+ }), (png) => `${Math.round(png.length / 1024)} KB`);
54
+ if (edited) fs.writeFileSync(path.join(out, 'edit.png'), edited);
55
+
56
+ const found = await check('sam', () => samFind(memo, { label: 'doctor', image: painting, prompt: 'barrel' }),
57
+ (f) => `${f.length} barrel(s), ${f.reduce((s, x) => s + maskArea(x.mask), 0)} px`);
58
+ if (found?.[0]) fs.writeFileSync(path.join(out, 'sam-barrel.png'), await maskToPng(found[0].mask));
59
+
60
+ if (nowAt >= 0) console.log(`\n ${liveVerdict(took)}`);
61
+ console.log(`\n sent: spark ${memo.sent.spark}, muse ${memo.sent.muse}, sam ${memo.sent.sam} · files in ${out}`);
62
+ if (failed) process.exit(1);
63
+ },
64
+ });
65
+
66
+ /** Slower than this, one call is a sign of a slow network or a busy Meta: a 25-drawing build took 45 min at 3x these. */
67
+ const SLOW_MS: Record<string, number> = { spark: 30_000, 'muse paint': 60_000, 'muse edit': 60_000, sam: 20_000 };
68
+
69
+ /** Whether a build in front of people will finish in time, from one call to each model. */
70
+ export function liveVerdict(took: Record<string, number>): string {
71
+ const slow = Object.entries(SLOW_MS).filter(([name, limit]) => (took[name] ?? Infinity) > limit).map(([name]) => name);
72
+ if (!slow.length) return 'fast enough for a live build (3 places in about 15 min)';
73
+ return `SLOW: ${slow.join(', ')}. A live build may take 30+ min: play a world built beforehand, or build with fewer drawings and places`;
74
+ }
@@ -0,0 +1,123 @@
1
+ // diorama explore <slug> [place...]: walks the hero over every place in the real play page and
2
+ // reports what looks wrong (explore/ per place: report.json, heatmap.png, sheet.png).
3
+ // --fix closes the cells around bad spots in geometry.json; --no-judge skips the Spark juror.
4
+ import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import { Geometry } from '../play/geometry.ts';
7
+ import { classify, contactSheet, fixGrid, heatmap, ISSUES, judge, positions, type Checked } from '../qa/explore.ts';
8
+ import { CROP_JS, PROBE_JS, type ProbeHit } from '../qa/probe.ts';
9
+ import { defineCommand, takeOption } from '../registry.ts';
10
+ import { WorldFiles } from '../world/files.ts';
11
+ import { serveWorld } from './play.ts';
12
+
13
+ export type Browser = { newPage(o?: object): Promise<Page>; close(): Promise<void> };
14
+ export type Page = {
15
+ goto(url: string): Promise<unknown>;
16
+ waitForFunction(js: string, arg?: unknown, o?: object): Promise<unknown>;
17
+ evaluate<T>(js: string, arg?: unknown): Promise<T>;
18
+ on(event: 'pageerror' | 'console', fn: (e: { message?: string; text?: () => string; type?: () => string }) => void): void;
19
+ };
20
+
21
+ /** Playwright ships with diorama; its Chromium is a separate download (diorama browsers). */
22
+ export async function chromium(): Promise<{ launch(o?: object): Promise<Browser> }> {
23
+ try {
24
+ return ((await import('playwright' as string)) as { chromium: never }).chromium;
25
+ } catch {
26
+ try {
27
+ return (createRequire(import.meta.url)('playwright') as { chromium: never }).chromium;
28
+ } catch {
29
+ throw new Error('this command needs Playwright with Chromium: diorama browsers');
30
+ }
31
+ }
32
+ }
33
+
34
+ /** Runs a page function given as source text, so the bundler adds nothing it can't see. */
35
+ const call = <T>(page: Page, fn: string, arg: unknown) => page.evaluate<T>(`(${fn})(${JSON.stringify(arg)})`);
36
+
37
+ defineCommand({
38
+ name: 'explore',
39
+ usage: 'explore <slug> [place...] walk the hero over each place and check every frame [--fix] [--no-judge] [--port N]',
40
+ async run(args) {
41
+ const fix = args.includes('--fix');
42
+ const noJudge = args.includes('--no-judge');
43
+ const port = Number(takeOption(args, 'port', '4183'));
44
+ const [slug, ...only] = args.filter((a) => !a.startsWith('--'));
45
+ if (!slug) throw new Error('usage: diorama explore <slug> [place...] [--fix] [--no-judge]');
46
+ const files = new WorldFiles(slug);
47
+ const world = files.world();
48
+ const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
49
+
50
+ const server = await serveWorld(files, port);
51
+ const browser = await (await chromium()).launch();
52
+ try {
53
+ const page = await browser.newPage({ viewport: { width: 1280, height: 960 } });
54
+ page.on('pageerror', (e) => console.log(` page error: ${e.message}`));
55
+ for (const place of places) {
56
+ const at = (...parts: string[]) => files.place(place.key, ...parts);
57
+ if (!fs.existsSync(at('geometry.json'))) {
58
+ console.log(`${place.key}: no geometry.json, skipped`);
59
+ continue;
60
+ }
61
+ const g = Geometry.parse(files.readJson(at('geometry.json')));
62
+ await page.goto(`http://localhost:${port}/?capture=1&place=${place.key}`);
63
+ await page.waitForFunction(`window.diorama && window.diorama.place && window.diorama.place.key === ${JSON.stringify(place.key)}`, undefined, { timeout: 30000 });
64
+
65
+ const spots = positions(g);
66
+ const started = Date.now();
67
+ const hits: ProbeHit[] = [];
68
+ for (let i = 0; i < spots.length; i += 100) hits.push(...(await call<ProbeHit[]>(page, PROBE_JS, { placeKey: place.key, positions: spots.slice(i, i + 100) })));
69
+ const checked = hits.map(classify);
70
+ const bad = checked.filter((h) => h.issues.length);
71
+ const counts = Object.fromEntries(ISSUES.map((k) => [k, checked.filter((h) => h.issues.includes(k)).length]));
72
+ console.log(`${place.key}: ${checked.length} spots in ${((Date.now() - started) / 1000).toFixed(1)} s, ${bad.length} with issues ${JSON.stringify(counts)}`);
73
+
74
+ fs.rmSync(at('explore'), { recursive: true, force: true });
75
+ const painting = fs.readFileSync(at('painting.png'));
76
+ files.write(at('explore', 'heatmap.png'), await heatmap(painting, g, checked));
77
+
78
+ // The juror: the worst spots first, the rest random clean ones, so it also says when all is fine.
79
+ let jury: { tiles: Checked[]; wrong: { id: number; why: string }[] } | undefined;
80
+ if (!noJudge) {
81
+ const severity = (h: Checked) => h.issues.length * 1000 + h.drawn - h.hidden;
82
+ const worst = [...bad].sort((a, b) => severity(b) - severity(a)).slice(0, 10);
83
+ const clean = checked.filter((h) => !h.issues.length);
84
+ const random = Array.from({ length: Math.min(16 - worst.length, clean.length) }, (_, i) => clean[Math.floor(((i + 0.5) * clean.length) / Math.min(16 - worst.length, clean.length))]);
85
+ const tiles = [...worst, ...random];
86
+ const crops = await Promise.all(tiles.map(async (t) => {
87
+ const url = await call<string>(page, CROP_JS, { placeKey: place.key, x: t.x, y: t.y, size: 256 });
88
+ return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64');
89
+ }));
90
+ const sheet = await contactSheet(crops, 256);
91
+ files.write(at('explore', 'sheet.png'), sheet);
92
+ const wrong = await judge(files.memo, `explore:${place.key}`, sheet, tiles.length);
93
+ jury = { tiles, wrong };
94
+ const agree = wrong.filter((w) => tiles[w.id - 1].issues.length).length;
95
+ console.log(` juror: ${wrong.length} of ${tiles.length} tiles look wrong (${agree} also failed a code check)${wrong.map((w) => `\n #${w.id} ${tiles[w.id - 1].issues.join('+') || 'clean'}: ${w.why}`).join('')}`);
96
+ }
97
+
98
+ let fixed: { closed: number; undone: string[] } | undefined;
99
+ if (fix && bad.length) {
100
+ const result = fixGrid(g, checked);
101
+ fixed = { closed: result.closed, undone: result.undone };
102
+ if (result.closed) {
103
+ const flagged = [...(g.flagged ?? []), `explorer closed ${result.closed} cells where the hero looked wrong`];
104
+ files.write(at('geometry.json'), Geometry.parse({ ...result.geometry, flagged }));
105
+ }
106
+ console.log(` fix: closed ${result.closed} cells${result.undone.map((u) => `; not fixed: ${u}`).join('')}`);
107
+ }
108
+
109
+ files.write(at('explore', 'report.json'), {
110
+ spots: checked.length,
111
+ counts,
112
+ bad: bad.map(({ x, y, issues, worst, drawn, hidden }) => ({ x, y, issues, worst, drawn, hidden })),
113
+ ...(jury && { juror: jury.wrong.map((w) => ({ ...w, x: jury.tiles[w.id - 1].x, y: jury.tiles[w.id - 1].y, codeIssues: jury.tiles[w.id - 1].issues })) }),
114
+ ...(fixed && { fixed }),
115
+ });
116
+ }
117
+ } finally {
118
+ await browser.close();
119
+ server.close();
120
+ }
121
+ console.log(`spark calls sent: ${files.memo.sent.spark}`);
122
+ },
123
+ });
@@ -0,0 +1,158 @@
1
+ // diorama export <slug> [dir]: the world as a static folder (index.html, main.js, world-data.json, files/) any web host can serve.
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { defineCommand } from '../registry.ts';
7
+ import { WorldFiles } from '../world/files.ts';
8
+ import { bundle, WEB, worldData } from './play.ts';
9
+
10
+ /** A language name ("Polish", "polski") or code ("pl", "pt-BR") as a BCP 47 code, when Intl knows it. */
11
+ function langCode(language?: string): string | undefined {
12
+ if (!language) return undefined;
13
+ const t = language.trim();
14
+ if (/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/i.test(t)) return t;
15
+ const names = new Intl.DisplayNames(['en'], { type: 'language' });
16
+ for (const code of ['en', 'pl', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'cs', 'sk', 'uk', 'ru', 'sv', 'no', 'da', 'fi', 'hu', 'ro', 'el', 'tr', 'ja', 'ko', 'zh', 'ar', 'he', 'hi']) {
17
+ const own = new Intl.DisplayNames([code], { type: 'language' }).of(code);
18
+ if ([names.of(code), own].some((n) => n?.toLowerCase() === t.toLowerCase())) return code;
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
24
+
25
+ /** The link preview (X, Facebook, chats): the world's title and pitch, and a 1200x630 picture: the world's own og.jpg when it has one, else its first painted place. */
26
+ async function sharePreview(files: WorldFiles, out: string, url?: string): Promise<string> {
27
+ const world = files.world();
28
+ const title = `${world.title} · Diorama`;
29
+ const pitch = world.pitch.length > 200 ? `${world.pitch.slice(0, 197).replace(/\s+\S*$/, '')}…` : world.pitch;
30
+ const own = files.path('og.jpg');
31
+ const painted = [world.start, ...world.places.map((p) => p.key)].map((key) => files.place(key, 'painting.png')).find((f) => fs.existsSync(f));
32
+ const source = fs.existsSync(own) ? own : painted;
33
+ const base = url ? (url.endsWith('/') ? url : `${url}/`) : '';
34
+ const tags = [`<title>${esc(title)}</title>`, `<meta name="description" content="${esc(pitch)}">`, `<meta property="og:type" content="website">`, `<meta property="og:title" content="${esc(title)}">`, `<meta property="og:description" content="${esc(pitch)}">`];
35
+ if (base) tags.push(`<meta property="og:url" content="${esc(base)}">`);
36
+ if (source) {
37
+ await sharp(source).resize(1200, 630, { fit: 'cover', position: 'attention' }).jpeg({ quality: 85, mozjpeg: true }).toFile(path.join(out, 'og.jpg'));
38
+ // Crawlers want an absolute address; the stamp makes X fetch a new picture after a re-export.
39
+ const image = `${base}og.jpg?v=${Date.now().toString(36)}`;
40
+ tags.push(`<meta property="og:image" content="${esc(image)}">`, '<meta property="og:image:width" content="1200">', '<meta property="og:image:height" content="630">', `<meta property="og:image:alt" content="${esc(world.title)}, a playable paper diorama world">`, '<meta name="twitter:card" content="summary_large_image">', `<meta name="twitter:image" content="${esc(image)}">`);
41
+ } else tags.push('<meta name="twitter:card" content="summary">');
42
+ tags.push(`<meta name="twitter:title" content="${esc(title)}">`, `<meta name="twitter:description" content="${esc(pitch)}">`);
43
+ return tags.join('\n');
44
+ }
45
+
46
+ /** Writes the playable copy of world `slug` to `out`, replacing whatever was there; `home` adds a link back to the page it is shared from; `url` is where the copy will be served, so its link preview has absolute addresses. */
47
+ export async function exportWorld(slug: string, out: string, home?: string, url?: string): Promise<void> {
48
+ const files = new WorldFiles(slug);
49
+ const data = worldData(files);
50
+ fs.rmSync(out, { recursive: true, force: true });
51
+ fs.mkdirSync(out, { recursive: true });
52
+ const copy = async (rel: string, asJpeg = false): Promise<string> => {
53
+ const target = asJpeg ? rel.replace(/\.png$/, '.jpg') : rel;
54
+ const dest = path.join(out, 'files', target);
55
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
56
+ // Paintings are opaque, so JPEG keeps them a fraction of the size; masks and figures stay PNG.
57
+ if (asJpeg) await sharp(files.path(rel)).jpeg({ quality: 88, mozjpeg: true }).toFile(dest);
58
+ else fs.copyFileSync(files.path(rel), dest);
59
+ return target;
60
+ };
61
+ // Cut-outs, sprite sheets and icons only need their look and an exact edge: WebP with lossless alpha is a
62
+ // fraction of the PNG, and those files are most of what a player waits for. A file is changed only when it gets smaller.
63
+ const done = new Map<string, string>();
64
+ const art = async (rel: string): Promise<string> => {
65
+ if (done.has(rel)) return done.get(rel)!;
66
+ let target = rel;
67
+ const webp = /\.png$/i.test(rel) ? await sharp(files.path(rel)).webp({ quality: 90, alphaQuality: 100, effort: 5 }).toBuffer() : undefined;
68
+ if (webp && webp.length < fs.statSync(files.path(rel)).size * 0.9) {
69
+ target = rel.replace(/\.png$/i, '.webp');
70
+ const dest = path.join(out, 'files', target);
71
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
72
+ fs.writeFileSync(dest, webp);
73
+ } else await copy(rel);
74
+ done.set(rel, target);
75
+ return target;
76
+ };
77
+ /** `file` inside `dir`, as art; its new name, still inside `dir`. */
78
+ const artIn = async (dir: string, file: string) => path.posix.relative(dir, await art(`${dir}/${file}`));
79
+ for (const [key, entry] of Object.entries(data.places)) {
80
+ if (entry.painting) entry.painting = await copy(entry.painting, true);
81
+ if (entry.backdrop) entry.backdrop = await copy(entry.backdrop, true);
82
+ if (entry.layers) {
83
+ if (entry.layers.sky) entry.layers.sky = await copy(entry.layers.sky, true);
84
+ if (entry.layers.water) {
85
+ entry.layers.water.tex = await copy(entry.layers.water.tex);
86
+ entry.layers.water.mask = await copy(entry.layers.water.mask);
87
+ }
88
+ if (entry.layers.mist) {
89
+ entry.layers.mist.tex = await copy(entry.layers.mist.tex);
90
+ entry.layers.mist.mask = await copy(entry.layers.mist.mask);
91
+ }
92
+ for (const f of entry.layers.flats) f.file = await art(f.file); // transparent around the flat
93
+ for (const f of [...(entry.layers.smoke ?? []), ...(entry.layers.floats ?? []), ...(entry.layers.falls ?? []), ...(entry.layers.critters ?? []), ...(entry.layers.clouds?.bands ?? [])]) f.file = await art(f.file);
94
+ if (entry.layers.clouds) {
95
+ entry.layers.clouds.clip = await copy(entry.layers.clouds.clip);
96
+ if (entry.layers.clouds.keep) entry.layers.clouds.keep = await copy(entry.layers.clouds.keep);
97
+ }
98
+ }
99
+ for (const thing of entry.geometry?.things ?? []) {
100
+ if (thing.mask) await copy(`places/${key}/${thing.mask}`);
101
+ // A plate is the patch where the thing was, transparent elsewhere.
102
+ if (thing.plate) thing.plate = await artIn(`places/${key}`, thing.plate);
103
+ }
104
+ for (const occ of entry.geometry?.occluders ?? []) if (occ.mask) await copy(`places/${key}/${occ.mask}`);
105
+ if (entry.geometry?.shadows) await copy(`places/${key}/${entry.geometry.shadows}`);
106
+ if (entry.geometry?.depth) await copy(`places/${key}/${entry.geometry.depth}`);
107
+ if (entry.weather?.ripples) entry.weather.ripples = await copy(entry.weather.ripples);
108
+ }
109
+ for (const [i, rel] of Object.entries(data.changes)) data.changes[i] = await art(rel); // transparent around the thing
110
+ for (const [i, rel] of Object.entries(data.changeShapes)) data.changeShapes[i] = await copy(rel);
111
+ for (const figure of Object.values(data.cast)) {
112
+ figure.image = await art(figure.image);
113
+ if (figure.face) await copy(`${figure.face.dir}/${figure.face.info.image}`); // the face atlas is read cell by cell, so as is
114
+ for (const part of figure.rig?.parts ?? []) part.file = await artIn(figure.parts!, part.file);
115
+ if (figure.rig?.sheet) figure.rig.sheet.file = await artIn(figure.parts!, figure.rig.sheet.file);
116
+ if (figure.sprites) for (const f of [...Object.values(figure.sprites.info.facings), ...Object.values(figure.sprites.info.talk ?? {})]) f.sheet = await artIn(figure.sprites.dir, f.sheet);
117
+ // The page draws sprites when it has them; the 3D model behind them (a glb and four big views) is only for ?model=1.
118
+ if (figure.sprites) delete figure.model;
119
+ if (figure.model) for (const file of [figure.model.info.glb, ...Object.values(figure.model.info.views).map((v) => v.image)]) await copy(`${figure.model.dir}/${file}`);
120
+ }
121
+ for (const item of Object.values(data.items ?? {})) item.icon = await art(item.icon); // transparent
122
+ const recordings = Object.values(data.sound?.bySpeaker ?? {}).flatMap(lines => Object.values(lines));
123
+ const audioFiles = new Set([...Object.values(data.sound?.files ?? {}), ...Object.values(data.sound?.lines ?? {}), ...recordings].map(sound => sound.file));
124
+ for (const file of audioFiles) await copy(file);
125
+ fs.writeFileSync(path.join(out, 'world-data.json'), JSON.stringify(data));
126
+ fs.writeFileSync(path.join(out, 'main.js'), await bundle(true));
127
+ let page = fs.readFileSync(path.join(WEB, 'index.html'), 'utf8');
128
+ page = page.replace('<body>', '<body data-shipped>');
129
+ if (home) page = page.replace('<div id="soundless"', `<a id="home" href="${esc(home)}">← Diorama</a>\n<div id="soundless"`);
130
+ page = page.replace('<title>Diorama</title>', await sharePreview(files, out, url));
131
+ // The page's language follows the world's, so browsers and screen readers read a Polish world as Polish.
132
+ const lang = langCode(files.world().language);
133
+ if (lang) page = page.replace('<html lang="en">', `<html lang="${lang}">`);
134
+ fs.writeFileSync(path.join(out, 'index.html'), page);
135
+ }
136
+
137
+ defineCommand({
138
+ name: 'export',
139
+ usage: 'export <slug> [dir] [--zip] [--home <url>] [--url <url>] write a static, playable copy of the world (default exports/<slug>); --zip also packs it for itch.io or e-mail; --home puts a "← Diorama" link to <url> in the corner; --url is where it will be served, for the X/Facebook link preview (title, pitch, og.jpg: worlds/<slug>/og.jpg if you made one, else the first place)',
140
+ async run(args) {
141
+ const zip = args.includes('--zip');
142
+ const flag = (name: string) => { const i = args.indexOf(name); if (i >= 0 && !args[i + 1]) throw new Error(`${name} needs a url`); return i; };
143
+ const h = flag('--home'), u = flag('--url');
144
+ const home = h >= 0 ? args[h + 1] : undefined;
145
+ const url = u >= 0 ? args[u + 1] : undefined;
146
+ const [slug, dir = path.join('exports', slug ?? '')] = args.filter((a, i) => a !== '--zip' && ![h, u].some((f) => f >= 0 && (i === f || i === f + 1)));
147
+ if (!slug) throw new Error('usage: diorama export <slug> [dir] [--zip] [--home <url>] [--url <url>]');
148
+ const out = path.resolve(dir);
149
+ await exportWorld(slug, out, home, url);
150
+ console.log(`export ${slug}: ${out}`);
151
+ if (zip) {
152
+ // index.html at the zip's root, which is what itch.io wants for an HTML5 upload.
153
+ fs.rmSync(`${out}.zip`, { force: true });
154
+ execFileSync('zip', ['-qr', `${out}.zip`, '.'], { cwd: out });
155
+ console.log(`zip: ${out}.zip`);
156
+ }
157
+ },
158
+ });
@@ -0,0 +1,75 @@
1
+ // One Studio job: plan and validate first, then build only the anchor and additions.
2
+ import fs from 'node:fs';
3
+ import { defineCommand, emit, registry } from '../registry.ts';
4
+ import { readDrawn } from '../world/drawings.ts';
5
+ import { designExtension } from '../world/extend.ts';
6
+ import { stagedExtension } from '../world/extend-stage.ts';
7
+ import { WorldFiles, withThingEdits } from '../world/files.ts';
8
+ import { World } from '../world/schema.ts';
9
+ import { Story } from '../story/schema.ts';
10
+ import { checkStory } from '../story/check.ts';
11
+
12
+ export async function extendWorld(slug: string, selected: string[], wish: string) {
13
+ const files = new WorldFiles(slug);
14
+ const oldWorld = World.parse(files.readJson(files.path('world.json')));
15
+ const oldStory = Story.parse(files.readJson(files.path('story.json')));
16
+ const drawings = selected.map(file => {
17
+ const d = readDrawn(files).find(x => `drawings/${x.file}` === file);
18
+ if (!d || !fs.existsSync(files.path(file))) throw new Error(`${file} is not a saved, read drawing`);
19
+ return d;
20
+ });
21
+ // The story uses effective props (including manual edits), while only the base world is persisted.
22
+ const effective = withThingEdits(oldWorld, files.dir);
23
+ const designed = await designExtension(files.memo, effective, oldStory, drawings, wish);
24
+ // Preserve the corrections layer: use each unchanged original place and add only new things to the anchor.
25
+ const oldByKey = new Map(oldWorld.places.map(p => [p.key, p]));
26
+ const effectiveByKey = new Map(effective.places.map(p => [p.key, p]));
27
+ const world = { ...designed.world, places: designed.world.places.map(p => {
28
+ const old = oldByKey.get(p.key);
29
+ if (!old) return p;
30
+ if (!designed.changed.includes(p.key)) return old;
31
+ const before = effectiveByKey.get(p.key)!;
32
+ return { ...old, things: [...old.things, ...p.things.filter(t => !before.things.some(x => x.key === t.key))], exits: p.exits };
33
+ }) };
34
+ const checked = checkStory(withThingEdits(World.parse(world), files.dir), designed.story);
35
+ if (checked.issues.length) throw new Error(`story does not fit the corrected world: ${checked.issues.join('; ')}`);
36
+ const oldNpc = new Set(oldWorld.places.flatMap(p => p.things.filter(t => t.role === 'npc').map(t => t.key)));
37
+ const newNpc = [...new Set(world.places.flatMap(p => p.things.filter(t => t.role === 'npc' && !oldNpc.has(t.key)).map(t => t.key)))];
38
+ const newItems = designed.story.items.filter(i => !oldStory.items.some(o => o.key === i.key)).map(i => i.key);
39
+ files.write(files.path('world.json'), world);
40
+ files.write(files.path('story.json'), designed.story);
41
+ files.write(files.path('routes.json'), checked.routes);
42
+ console.log(`extension planned: ${designed.changed.join(', ')}; ${checked.states} story states checked`);
43
+ const newPlaces = designed.changed.filter(key => !oldWorld.places.some(place => place.key === key));
44
+ emit('extension.progress', { phase: 'places', total: designed.changed.length, newPlaces, ...(newNpc.length && { task: 'characters' }) });
45
+ // Builds run in the private copy; the original remains playable until the whole job succeeds.
46
+ if (newNpc.length) {
47
+ await registry.get('cast')!.run([slug, ...newNpc]);
48
+ const missing = newNpc.filter(key => !fs.existsSync(files.path('cast', `${key}.png`)));
49
+ if (missing.length) throw new Error(`characters could not be painted: ${missing.join(', ')}`);
50
+ }
51
+ for (const [index, place] of designed.changed.entries()) {
52
+ emit('extension.progress', { phase: 'places', place, index: index + 1, total: designed.changed.length });
53
+ console.log(`building ${place}`);
54
+ process.exitCode = 0;
55
+ await registry.get('build')!.run([slug, '--from', 'layout', '--to', 'verify', '--place', place]);
56
+ if (process.exitCode) throw new Error(`${place} is not playable; extension was not installed`);
57
+ }
58
+ emit('extension.progress', { phase: 'audio' });
59
+ if (newItems.length) await registry.get('icons')!.run([slug, ...newItems]);
60
+ await registry.get('voices')!.run([slug]);
61
+ await registry.get('sound')!.run([slug]);
62
+ console.log(`extension complete: ${designed.changed.join(', ')}; new characters: ${newNpc.join(', ') || 'none'}`);
63
+ }
64
+
65
+ defineCommand({
66
+ name: 'extend',
67
+ usage: 'extend <slug> <JSON drawing paths> <wish> extend a built world from saved drawings',
68
+ async run([slug, json, wish]) {
69
+ if (!slug || !json || !wish) throw new Error('usage: diorama extend <slug> <JSON drawing paths> <wish>');
70
+ const selected: unknown = JSON.parse(json);
71
+ if (!Array.isArray(selected) || !selected.length || !selected.every(f => typeof f === 'string' && /^drawings\/[a-z0-9-]+\.png$/.test(f))) throw new Error('drawings must be an array of drawings/*.png paths');
72
+ emit('extension.progress', { phase: 'plan' });
73
+ await stagedExtension(slug, () => extendWorld(slug, selected, wish));
74
+ },
75
+ });