@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,431 @@
1
+ // diorama clip <slug>: a short clip of the world for X, made with no hand editing. Plays the shortest ending route in
2
+ // real Chrome (GPU, ~60 fps screencast, a gliding cursor) at 1440x1080 with the camera still, so the whole 4:3
3
+ // painting is in every shot, records the last place on its own, picks the shots from the click log, cuts in each
4
+ // place orbiting and popping apart into its layers (app/demo/explode-take.ts --style orbit-popout) before its first
5
+ // shot and again under the end card, lays the world's own sound files under it (beds, loops, voices, music) and
6
+ // writes film/clip.mp4, film/clip-1080x1350.mp4 and film/clip-still-N.jpg. Both show the frame whole, the rest of the
7
+ // clip's frame filled with the same scene blurred (fillFrame). --zoom records the old way: 1920x1080 with the camera
8
+ // zoomed in and tracking the hero, the tall version a crop round the hero. The page's own recorded audio drops out
9
+ // on a loaded machine, so it is never used.
10
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import type { Page } from 'playwright';
16
+ import { audioPlan, clipSound, explodeFrame, fillFrame, LEVELS, pickShots, shotSeconds, shotStarts, type Cue, type Shot, type Take, type TakeEvent } from '../film/clip.ts';
17
+ import { openRecorder, type Recorder } from '../film/screencast.ts';
18
+ import { aimPoint, SETTLED, trackPending } from '../qa/route.ts';
19
+ import { defineCommand, takeOption } from '../registry.ts';
20
+ import { soundData } from '../sound/data.ts';
21
+ import { checkStory } from '../story/check.ts';
22
+ import { actions as storyActions, apply, startState, type Action } from '../story/engine.ts';
23
+ import type { Story } from '../story/schema.ts';
24
+ import { WorldFiles } from '../world/files.ts';
25
+ import { serveWorld } from './play.ts';
26
+
27
+ const ROOT = fileURLToPath(new URL('../../../', import.meta.url));
28
+ const FPS = 60;
29
+ const FONTS = ['/System/Library/Fonts/Supplemental/Arial Black.ttf', '/Library/Fonts/Arial Black.ttf', '/usr/share/fonts/truetype/msttcorefonts/Arial_Black.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'];
30
+ /** No words on screen but the game's own: the place name, the hover label, the controls hint and the bag go. */
31
+ const QUIET_UI = '#bar, #inventory, .held-cursor .label { display: none !important; }';
32
+ /** The recorded page: whole (the camera still, the 4:3 painting in full) or, with --zoom, zoomed in and tracking. */
33
+ const GAME_SIZE = { width: 1440, height: 1080 };
34
+ const ZOOM_SIZE = { width: 1920, height: 1080 };
35
+ const ZOOM_END_SIZE = { width: 1920, height: 1440 };
36
+
37
+ /** Logs the page's events and where the hero is on the page, in wall-clock seconds. */
38
+ const LOGGER = `(() => {
39
+ const g = window.diorama;
40
+ window.__clipLog = [];
41
+ window.__heroLog = [];
42
+ g.on((ev, d) => window.__clipLog.push({ ev, d, wall: Date.now() / 1000 }));
43
+ setInterval(() => {
44
+ if (!g.place || !g.hero) return;
45
+ const c = document.querySelector('canvas');
46
+ const r = c.getBoundingClientRect();
47
+ const W = g.place.g.frame.width;
48
+ const x = r.left + ((g.hero.x - (g.viewX || W / 2)) * g.zoom() + W / 2) * (r.width / c.width);
49
+ window.__heroLog.push({ wall: Date.now() / 1000, x });
50
+ }, 100);
51
+ })()`;
52
+
53
+ /** A page point on open floor between the hero and `target` (or anywhere near the hero), to set them walking. */
54
+ const floorPoint = (target: string | undefined) => `(() => {
55
+ const g = window.diorama;
56
+ const c = document.querySelector('canvas');
57
+ const r = c.getBoundingClientRect();
58
+ const { width: W, height: H } = g.place.g.frame;
59
+ const z = g.zoom(), k = r.width / c.width;
60
+ const toPage = (x, y) => [r.left + ((x - (g.viewX || W / 2)) * z + W / 2) * k, r.top + ((y - (g.viewY || H / 2)) * z + H / 2) * k];
61
+ const toFrame = (px, py) => g.unzoom((px - r.left) / r.width * c.width, (py - r.top) / r.height * c.height);
62
+ const hot = ${JSON.stringify(target ?? null)} && g.hots().find((h) => h.thing.key === ${JSON.stringify(target ?? null)});
63
+ const goal = hot ? g.approach(hot) : null;
64
+ const tries = [];
65
+ if (goal) for (const u of [0.45, 0.35, 0.55, 0.25, 0.65, 0.8]) tries.push([g.hero.x + (goal[0] - g.hero.x) * u, g.hero.y + (goal[1] - g.hero.y) * u]);
66
+ for (const d of [0.14, 0.09, 0.2]) for (let a = 0; a < 12; a++) tries.push([g.hero.x + Math.cos(a * Math.PI / 6) * W * d, g.hero.y + Math.sin(a * Math.PI / 6) * W * d * 0.5]);
67
+ for (const [fx, fy] of tries) {
68
+ const [px, py] = toPage(fx, fy);
69
+ if (px < 60 || py < 60 || px > innerWidth - 60 || py > innerHeight - 60) continue;
70
+ if (document.elementFromPoint(px, py) !== c) continue;
71
+ const f = toFrame(px, py);
72
+ if (g.thingAt(...f)) continue;
73
+ const fl = g.floorAt(...f);
74
+ if (!g.place.grid.walkable(...fl) || Math.hypot(fl[0] - g.hero.x, fl[1] - g.hero.y) < W * 0.05) continue;
75
+ return { x: px, y: py };
76
+ }
77
+ return null;
78
+ })()`;
79
+
80
+ const targetOf = (a: Action) => (a.kind === 'go' ? a.via : a.kind === 'talk' ? a.npc : a.kind === 'do' ? a.thing : undefined);
81
+
82
+ type Logged = { ev: string; d: Record<string, unknown>; wall: number };
83
+
84
+ /** Turns the page's logs and our clicks into a Take on the video's clock. */
85
+ async function takeOf(page: Page, first: number, seconds: number, place: string, clicks: Array<{ wall: number; x: number; y: number; place: string; action?: Action }>): Promise<Take> {
86
+ const log = await page.evaluate<Logged[]>('window.__clipLog');
87
+ const hero = await page.evaluate<Array<{ wall: number; x: number }>>('window.__heroLog');
88
+ const t = (wall: number) => Math.round((wall - first) * 1000) / 1000;
89
+ const events: TakeEvent[] = clicks.map((c) => ({ kind: 'click', t: t(c.wall), x: c.x, y: c.y, place: c.place, ...(c.action && { action: c.action }) }));
90
+ for (const e of log) {
91
+ if (e.ev === 'use') events.push({ kind: 'use', t: t(e.wall), thing: String(e.d.thing) });
92
+ else if (e.ev === 'say') events.push({ kind: 'say', t: t(e.wall), text: String(e.d.text), who: String(e.d.who) });
93
+ else if (e.ev === 'exit') events.push({ kind: 'exit', t: t(e.wall), from: String(e.d.from), to: String(e.d.to) });
94
+ else if (e.ev === 'enter') events.push({ kind: 'enter', t: t(e.wall), place: String(e.d.place) });
95
+ }
96
+ events.sort((a, b) => a.t - b.t);
97
+ return { duration: Math.round(seconds * 1000) / 1000, place, events: events.filter((e) => e.t >= 0), hero: hero.map((h) => ({ t: t(h.wall), x: Math.round(h.x) })).filter((h) => h.t >= 0) };
98
+ }
99
+
100
+ /** Opens the page at `query`, waits for the place, quiets the click shot and the UI text, starts the logs. */
101
+ async function ready(rec: Recorder, url: string, css: string, log: (s: string) => void) {
102
+ const { page } = rec;
103
+ page.on('pageerror', (e) => log(` page error: ${e.message}`));
104
+ await page.goto(url);
105
+ await page.waitForFunction('window.diorama && window.diorama.place && window.diorama.dark === 0', undefined, { timeout: 120_000 });
106
+ // The game's own click shot is too harsh in a clip; the soundtrack is laid in the edit anyway.
107
+ await page.evaluate('(() => { const m = window.diorama.sound; if (m && m.sound && m.sound.shots) delete m.sound.shots.click; m && m.ctx && m.ctx.resume(); })()');
108
+ await page.addStyleTag({ content: css });
109
+ await trackPending(page);
110
+ await page.evaluate(LOGGER);
111
+ }
112
+
113
+ const settled = (page: Page) => page.waitForFunction(SETTLED, undefined, { timeout: 120_000 });
114
+
115
+ /** Plays `actions` in real time from the first place; the floor click first, so the hero is mid-stride. */
116
+ async function gameTake(port: number, work: string, actions: Action[], zoom: boolean, log: (s: string) => void): Promise<{ take: Take; video: string }> {
117
+ const size = zoom ? ZOOM_SIZE : GAME_SIZE;
118
+ const rec = await openRecorder(size.width, size.height, work, log);
119
+ const { page } = rec;
120
+ try {
121
+ await ready(rec, `http://localhost:${port}/?parallax=1.5&sway=1${zoom ? '&track=1&fill=1' : ''}`, QUIET_UI, log);
122
+ await page.mouse.move(size.width * 0.57, size.height * 0.76);
123
+ await page.waitForTimeout(1500);
124
+ // The narrator's intro is over before the clip starts.
125
+ await settled(page);
126
+ await page.waitForTimeout(300);
127
+ const place = await page.evaluate<string>('window.diorama.place.key');
128
+ const clicks: Array<{ wall: number; x: number; y: number; place: string; action?: Action }> = [];
129
+ await rec.start();
130
+ await page.waitForTimeout(400);
131
+ const floor = await page.evaluate<{ x: number; y: number } | null>(floorPoint(actions[0] && targetOf(actions[0])));
132
+ if (floor) {
133
+ clicks.push({ wall: await rec.click(floor.x, floor.y, { ms: 250, dwell: 60 }), ...floor, place });
134
+ await page.waitForTimeout(650);
135
+ } else log(' no open floor to walk to first; the clip opens on the first story click');
136
+ for (const [i, a] of actions.entries()) {
137
+ if (i > 0) {
138
+ await settled(page);
139
+ await page.waitForTimeout(250);
140
+ }
141
+ const here = await page.evaluate<string>('window.diorama.place.key');
142
+ log(` ${i + 1}/${actions.length} ${JSON.stringify(a)}`);
143
+ if (a.kind === 'choose' || a.kind === 'leave') {
144
+ const button = page.locator(`#talk button[data-action=${JSON.stringify(a.kind === 'choose' ? `choose:${a.index}` : 'leave')}]`);
145
+ await button.scrollIntoViewIfNeeded().catch(() => {});
146
+ const b = await button.boundingBox();
147
+ if (!b) throw new Error(`no answer button for ${JSON.stringify(a)}`);
148
+ const at = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
149
+ clicks.push({ wall: await rec.click(at.x, at.y, { ms: 450, dwell: 150 }), ...at, place: here, action: a });
150
+ continue;
151
+ }
152
+ const step: { use: string; verb?: string; with?: string } = a.kind === 'go' ? { use: a.via } : a.kind === 'talk' ? { use: a.npc } : { use: a.thing, verb: a.verb, ...(a.with ? { with: a.with } : {}) };
153
+ // The bag is hidden in the clip: the item is lifted from it unseen and rides the cursor to its target.
154
+ const pick = async () => {
155
+ if (!step.with) return;
156
+ const slot = page.locator(`#inventory button[data-item=${JSON.stringify(step.with)}]`);
157
+ if (!(await slot.count())) throw new Error(`${step.with} is not in the bag`);
158
+ await slot.dispatchEvent('click');
159
+ await page.waitForTimeout(250);
160
+ };
161
+ await pick();
162
+ // A step the slow machine left half done (an item still in hand): drop what is held, settle, aim again.
163
+ let p = await aimPoint(page, step).catch(() => null);
164
+ if (!p) {
165
+ log(` retry ${step.use}`);
166
+ await page.keyboard.press('Escape');
167
+ await settled(page).catch(() => {});
168
+ await page.waitForTimeout(500);
169
+ await pick();
170
+ p = await aimPoint(page, step);
171
+ }
172
+ if (!p) throw new Error(`no clickable pixel for ${step.use}`);
173
+ clicks.push({ wall: await rec.click(p.x, p.y, { ms: 650, button: step.verb === 'look' ? 'right' : 'left' }), ...p, place: here, action: a });
174
+ }
175
+ await settled(page).catch(() => {});
176
+ await page.waitForTimeout(2500);
177
+ const video = path.join(work, 'video.mp4');
178
+ const v = await rec.stop(video, FPS);
179
+ return { take: await takeOf(page, v.first, v.seconds, place, clicks), video };
180
+ } finally {
181
+ await rec.close();
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The last place on its own, whole (no fill; 1440x1080, or 1920x1440 with --zoom so a tall thing stays in view), the
187
+ * hero setting off for the next thing the route does there: really, when the fresh place allows it (a talk), else a
188
+ * walk to it.
189
+ */
190
+ async function endTake(port: number, work: string, place: string, next: Action | undefined, valid: boolean, zoom: boolean, log: (s: string) => void): Promise<{ take: Take; video: string }> {
191
+ const size = zoom ? ZOOM_END_SIZE : GAME_SIZE;
192
+ const rec = await openRecorder(size.width, size.height, work, log);
193
+ const { page } = rec;
194
+ try {
195
+ await ready(rec, `http://localhost:${port}/?place=${encodeURIComponent(place)}&parallax=1.5&sway=1`, `${QUIET_UI} #talk { display: none !important; }`, log);
196
+ await page.waitForFunction('!window.diorama.busy && !window.diorama.speech', undefined, { timeout: 60_000 }).catch(() => {});
197
+ await page.mouse.move(size.width / 2, size.height * 0.62);
198
+ await page.waitForTimeout(1200);
199
+ const clicks: Array<{ wall: number; x: number; y: number; place: string; action?: Action }> = [];
200
+ await rec.start();
201
+ await page.waitForTimeout(300);
202
+ const target = next && targetOf(next);
203
+ const step = next && target ? (next.kind === 'do' ? { use: target, verb: next.verb } : { use: target }) : undefined;
204
+ const aimed = valid && step ? await aimPoint(page, step).catch(() => null) : null;
205
+ if (aimed && next) {
206
+ log(` end: ${JSON.stringify(next)}`);
207
+ clicks.push({ wall: await rec.click(aimed.x, aimed.y, { ms: 400, dwell: 150 }), ...aimed, place, action: next });
208
+ } else {
209
+ // Not something the fresh place allows: the hero only walks to it.
210
+ const rect = target ? await page.evaluate<{ x: number; y: number } | null>(`(() => { const r = window.diorama.pageRect(${JSON.stringify(target)}); return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null; })()`) : null;
211
+ const goal = rect && await page.evaluate<boolean>(`(() => { const g = window.diorama; const hot = g.hots().find((h) => h.thing.key === ${JSON.stringify(target)}); if (!hot) return false; void g.walkTo(...g.approach(hot)); return true; })()`).then(() => rect);
212
+ const at = goal ?? (await page.evaluate<{ x: number; y: number } | null>(floorPoint(undefined)));
213
+ if (at && goal) clicks.push({ wall: await rec.tap(at.x, at.y, { ms: 400, dwell: 150 }), ...at, place });
214
+ else if (at) clicks.push({ wall: await rec.click(at.x, at.y, { ms: 400, dwell: 150 }), ...at, place });
215
+ log(` end: walk ${goal ? `to ${target}` : at ? 'across the floor' : '(nowhere to walk)'}`);
216
+ }
217
+ await page.waitForTimeout(10_000);
218
+ const video = path.join(work, 'video.mp4');
219
+ const v = await rec.stop(video, FPS);
220
+ return { take: await takeOf(page, v.first, v.seconds, place, clicks), video };
221
+ } finally {
222
+ await rec.close();
223
+ }
224
+ }
225
+
226
+ /** The place orbiting and popping apart into its layers (a 4 s take, app/demo/explode-take.ts); undefined when that is not there. */
227
+ function explodeTake(slug: string, place: string, out: string, log: (s: string) => void): string | undefined {
228
+ const script = path.join(ROOT, 'app/demo/explode-take.ts');
229
+ const tsx = path.join(ROOT, 'node_modules/.bin/tsx');
230
+ if (!fs.existsSync(script) || !fs.existsSync(tsx)) {
231
+ log(' no app/demo/explode-take.ts in this install: no layer explode');
232
+ return undefined;
233
+ }
234
+ try {
235
+ execFileSync(tsx, [script, slug, place, '--style', 'orbit-popout', '--seconds', '4', '--out', out], { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'inherit'] });
236
+ } catch (error) {
237
+ log(` explode of ${place} failed (${(error as Error).message.split('\n')[0]}); going on without it`);
238
+ return undefined;
239
+ }
240
+ const mp4 = path.join(out, `${slug}--${place}.mp4`);
241
+ return fs.existsSync(mp4) ? mp4 : undefined;
242
+ }
243
+
244
+ const ff = (args: string[]) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args], { stdio: ['ignore', 'ignore', 'inherit'] });
245
+ export const duration = (f: string) => Number(execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f]).toString().trim());
246
+
247
+ function caption(text: string | undefined, size: number, y: number): string {
248
+ if (!text) return '';
249
+ const font = FONTS.find((f) => fs.existsSync(f));
250
+ const t = text.replace(/\\/g, '').replace(/'/g, '\u2019').replace(/:/g, '\\:').replace(/%/g, '\\%');
251
+ return `,drawtext=${font ? `fontfile='${font}':` : ''}text='${t}':fontsize=${size}:fontcolor=white:box=1:boxcolor=0x141a2e@0.88:boxborderw=${Math.round(size / 3)}:x=(w-tw)/2:y=h-th-${y}`;
252
+ }
253
+
254
+ type Sources = { game: string; end: string; explode: Record<string, string>; frames: Record<string, { width: number; height: number }> };
255
+
256
+ /**
257
+ * How a shot sits in a w x h clip frame: a game or end frame whole with the blurred fill round it, an explode cut
258
+ * round its flat painting; with --zoom the old way (the end take's middle 16:9 band, the tall one a crop round the hero).
259
+ */
260
+ function shotFrame(s: Shot, frames: Sources['frames'], w: number, h: number, zoom: boolean): string {
261
+ if (s.source === 'explode') return explodeFrame(frames[s.place], w, h);
262
+ if (!zoom) return fillFrame(GAME_SIZE, w, h);
263
+ const fit = s.source === 'end' ? `crop=${ZOOM_SIZE.width}:${ZOOM_SIZE.height}:0:${(ZOOM_END_SIZE.height - ZOOM_SIZE.height) / 2}` : `scale=${ZOOM_SIZE.width}:${ZOOM_SIZE.height}`;
264
+ return w >= h ? fit : `${fit},scale=2400:1350:flags=lanczos,crop=1080:1350:${s.cropX}:0`;
265
+ }
266
+
267
+ /** Each shot to a 16:9 and a 1080x1350 segment, then each list joined with a re-encode (a stream-copy join of mixed encodes shows colour garbage). */
268
+ function renderVideo(shots: Shot[], sources: Sources, work: string, zoom: boolean): { wide: string; tall: string } {
269
+ const wide: string[] = [], tall: string[] = [];
270
+ shots.forEach((s, i) => {
271
+ const src = s.source === 'explode' ? sources.explode[s.place] : sources[s.source];
272
+ if (!src) throw new Error(`no ${s.source} take for ${s.place}`);
273
+ const d = (s.to - s.from) / s.speed, hold = s.hold ?? 0;
274
+ const pts = s.speed !== 1 ? `setpts=(PTS-STARTPTS)/${s.speed},` : '';
275
+ const tail = hold ? `,tpad=stop_mode=clone:stop_duration=${hold}` : '';
276
+ const input = ['-ss', String(s.from), '-t', String(s.to - s.from), '-i', src, '-an'];
277
+ // The caption sits low in 16:9 and in the bar under the frame in 1080x1350.
278
+ for (const [w, h, size, y, crf, list, name] of [[1920, 1080, 76, 110, '16', wide, 'w'], [1080, 1350, 62, zoom ? 160 : 170, '17', tall, 'v']] as const) {
279
+ const out = path.join(work, `${name}${i}.mp4`);
280
+ ff([...input, '-filter_complex', `[0:v]${shotFrame(s, sources.frames, w, h, zoom)},${pts}fps=${FPS},format=yuv420p${caption(s.caption, size, y)}${tail}[v]`, '-map', '[v]',
281
+ '-t', (d + hold).toFixed(3), '-c:v', 'libx264', '-crf', crf, out]);
282
+ list.push(out);
283
+ }
284
+ });
285
+ const join = (name: string, parts: string[]) => {
286
+ const list = path.join(work, `${name}.txt`);
287
+ fs.writeFileSync(list, parts.map((p) => `file '${p}'\n`).join(''));
288
+ const out = path.join(work, `${name}.mp4`);
289
+ ff(['-f', 'concat', '-safe', '0', '-i', list, '-vf', `fps=${FPS},format=yuv420p`, '-c:v', 'libx264', '-crf', '16', '-preset', 'medium', out]);
290
+ return out;
291
+ };
292
+ return { wide: join('wide', wide), tall: join('tall', tall) };
293
+ }
294
+
295
+ /** The cues mixed to one wav: each normalised and placed, then the whole to -14 LUFS. */
296
+ export function renderAudio(cues: Cue[], total: number, out: string, dir: string) {
297
+ const inputs: string[] = [], chains: string[] = [], labels: string[] = [];
298
+ for (const [n, c] of cues.entries()) {
299
+ inputs.push(...(c.loop ? ['-stream_loop', '-1'] : []), '-i', path.join(dir, c.file));
300
+ const ms = Math.round(c.at * 1000);
301
+ chains.push(`[${n}:a]atrim=0:${c.seconds.toFixed(3)},asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,loudnorm=I=${c.lufs}:TP=${c.kind === 'voice' ? -3 : c.kind === 'music' ? -4 : -6},aresample=48000,asetpts=N/SR/TB,volume=${c.gain},`
302
+ + `afade=t=in:d=${c.fadeIn},afade=t=out:st=${Math.max(0, c.seconds - c.fadeOut).toFixed(3)}:d=${c.fadeOut},adelay=${ms}|${ms},asetpts=N/SR/TB,apad=whole_dur=${total.toFixed(3)},atrim=0:${total.toFixed(3)}[x${n}]`);
303
+ labels.push(`[x${n}]`);
304
+ }
305
+ // Silence the length of the clip under it all; each cue is padded to that length too (amix with delayed,
306
+ // shorter inputs ends early).
307
+ inputs.push('-f', 'lavfi', '-t', total.toFixed(3), '-i', 'anullsrc=r=48000:cl=stereo');
308
+ labels.push(`[${cues.length}:a]`);
309
+ // The whole is normalised in a second pass: loudnorm right after this amix ends the track early.
310
+ const raw = out.replace(/(\.wav)?$/, '.raw.wav');
311
+ ff([...inputs, '-filter_complex', `${chains.join(';')}${chains.length ? ';' : ''}${labels.join('')}amix=inputs=${labels.length}:duration=longest:normalize=0,apad=whole_dur=${total.toFixed(3)},atrim=0:${total.toFixed(3)}[a]`,
312
+ '-map', '[a]', '-ar', '48000', '-c:a', 'pcm_s24le', raw]);
313
+ ff(['-i', raw, '-af', `apad=pad_dur=4,loudnorm=I=${LEVELS.final}:TP=-1.5:LRA=11,aresample=48000,atrim=0:${total.toFixed(3)}`, '-ar', '48000', out]);
314
+ fs.rmSync(raw, { force: true });
315
+ }
316
+
317
+ defineCommand({
318
+ name: 'clip',
319
+ usage: 'clip <slug> a short clip for X: plays the shortest ending, cuts it round the clicks with each place coming apart and the world\'s own sound (film/clip.mp4, film/clip-1080x1350.mp4, film/clip-still-N.jpg) [--seconds 38] [--music file] [--no-explode] [--zoom] [--caption "Send me your sketch."] [--port 4198] [--keep]',
320
+ async run(args) {
321
+ // The whole clip, explodes and end card included; the story shots get what those leave (never less than ~2.4 s a place).
322
+ const seconds = Number(takeOption(args, 'seconds', '38'));
323
+ const musicArg = takeOption(args, 'music', '');
324
+ const port = Number(takeOption(args, 'port', '4198'));
325
+ const text = takeOption(args, 'caption', 'Send me your sketch.');
326
+ const explode = !args.includes('--no-explode');
327
+ const zoom = args.includes('--zoom');
328
+ const keep = args.includes('--keep');
329
+ const [slug] = args.filter((a) => !a.startsWith('--'));
330
+ if (!slug) throw new Error('usage: diorama clip <slug> [--seconds 38] [--music file] [--no-explode] [--zoom] [--port N]');
331
+ if (!(seconds > 5)) throw new Error('--seconds must be more than 5');
332
+ const files = new WorldFiles(slug);
333
+ const world = files.world();
334
+ const report = checkStory(world, files.readJson(files.path('story.json')));
335
+ if (report.issues.length || !report.story) throw new Error(`story.json does not check out:\n- ${report.issues.join('\n- ')}`);
336
+ const story: Story = report.story;
337
+ const route = [...report.routes].sort((a, b) => a.actions.length - b.actions.length)[0];
338
+ if (!route) throw new Error(`${slug} has no route to an ending`);
339
+ const log = (s: string) => console.log(s);
340
+
341
+ // The game take runs to the last change of place; the last place is recorded on its own.
342
+ let state = startState(world);
343
+ const placeBefore: string[] = [];
344
+ for (const a of route.actions) {
345
+ placeBefore.push(state.place);
346
+ state = apply(world, story, state, a).state;
347
+ }
348
+ const lastGo = route.actions.map((a) => a.kind).lastIndexOf('go');
349
+ const played = lastGo >= 0 ? route.actions.slice(0, lastGo + 1) : route.actions.slice(0, -1);
350
+ const firstPlace = startState(world).place;
351
+ const lastPlace = lastGo >= 0 ? (placeBefore[lastGo + 1] ?? state.place) : firstPlace;
352
+ const next = route.actions.slice(played.length).find((a) => a.kind === 'talk' || a.kind === 'do');
353
+ const fresh = { ...startState(world), place: lastPlace };
354
+ const valid = !!next && storyActions(world, story, fresh).some((a) => JSON.stringify(a) === JSON.stringify(next));
355
+ log(`clip ${slug}: ${route.ending} in ${route.actions.length} clicks, ${played.length} played through, ending on ${lastPlace}`);
356
+
357
+ const work = path.join(os.tmpdir(), `diorama-clip-${slug}`);
358
+ fs.rmSync(work, { recursive: true, force: true });
359
+ fs.mkdirSync(work, { recursive: true });
360
+ // An explode for every place the clip shows: the places the played route passes through, and the last one.
361
+ const explodes: Record<string, string> = {};
362
+ if (explode) {
363
+ log('explode takes (each place orbits and pops apart into its layers)');
364
+ for (const place of new Set([...placeBefore.slice(0, played.length), lastPlace])) {
365
+ const mp4 = explodeTake(slug, place, path.join(work, 'explode'), log);
366
+ if (mp4) explodes[place] = mp4;
367
+ }
368
+ }
369
+ const frames = Object.fromEntries(Object.keys(explodes).map((p) => [p, (files.readJson(files.path('places', p, 'geometry.json')) as { frame: { width: number; height: number } }).frame]));
370
+ const server = await serveWorld(files, port);
371
+ let game: Awaited<ReturnType<typeof gameTake>>, end: Awaited<ReturnType<typeof endTake>>;
372
+ try {
373
+ log(`game take at http://localhost:${port}`);
374
+ game = await gameTake(port, path.join(work, 'game'), played, zoom, log);
375
+ log(`end take in ${lastPlace}`);
376
+ end = await endTake(port, path.join(work, 'end'), lastPlace, next, valid, zoom, log);
377
+ } finally {
378
+ server.close();
379
+ }
380
+ const sources: Sources = { game: game.video, end: end.video, explode: explodes, frames };
381
+
382
+ const shots = pickShots({
383
+ game: game.take, end: end.take, seconds, firstPlace, lastPlace, caption: text,
384
+ explodes: Object.fromEntries(Object.entries(explodes).map(([p, f]) => [p, duration(f)])),
385
+ });
386
+ const total = shots.reduce((n, s) => n + shotSeconds(s), 0);
387
+ // What was made, even where the page now leaves a stale file out; only files that are there.
388
+ const madeFile = files.path('sound', 'sounds.json');
389
+ const made = fs.existsSync(madeFile) ? Object.fromEntries(Object.entries((files.readJson(madeFile) as { sounds: Record<string, { file: string; kind: string; level: number }> }).sounds ?? {}).filter(([, s]) => fs.existsSync(files.path(s.file)))) : {};
390
+ const sound = clipSound(soundData(files), made, world.places.map((p) => p.key));
391
+ const music = musicArg ? path.resolve(musicArg) : sound?.music ? files.path(sound.files[sound.music].file) : undefined;
392
+ // Cue files are relative to the world (an outside --music file as a ../ path).
393
+ const cues = audioPlan(shots, { game: game.take, end: end.take }, sound, music && path.relative(files.dir, music));
394
+ const starts = shotStarts(shots);
395
+ shots.forEach((s, i) => log(` ${starts[i].toFixed(2).padStart(6)}s ${s.source.padEnd(8)} ${s.from.toFixed(2)}-${s.to.toFixed(2)}${s.speed !== 1 ? ` x${s.speed}` : ''} ${s.why}`));
396
+ log(`${shots.length} shots, ${total.toFixed(1)} s; ${cues.filter((c) => c.kind === 'voice').length} voiced line(s)`);
397
+
398
+ log('rendering');
399
+ const cut = path.join(work, 'cut');
400
+ fs.mkdirSync(cut, { recursive: true });
401
+ const video = renderVideo(shots, sources, cut, zoom);
402
+ const mix = path.join(cut, 'mix.wav');
403
+ renderAudio(cues, total, mix, files.dir);
404
+ const film = files.path('film');
405
+ fs.mkdirSync(film, { recursive: true });
406
+ const outWide = path.join(film, 'clip.mp4'), outTall = path.join(film, 'clip-1080x1350.mp4');
407
+ for (const [src, out] of [[video.wide, outWide], [video.tall, outTall]]) {
408
+ ff(['-i', src, '-i', mix, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', out]);
409
+ }
410
+ // Stills: the first place apart, a few story shots, the last place, the end card.
411
+ for (const f of fs.readdirSync(film).filter((f) => /^clip-still-\d+\.jpg$/.test(f))) fs.rmSync(path.join(film, f));
412
+ const picks = stillShots(shots);
413
+ const stills = picks.map((i, n) => {
414
+ const file = path.join(film, `clip-still-${n + 1}.jpg`);
415
+ const at = starts[i] + shotSeconds(shots[i]) * (shots[i].caption ? 0.85 : 0.6);
416
+ ff(['-ss', at.toFixed(3), '-i', outWide, '-frames:v', '1', '-q:v', '2', file]);
417
+ return file;
418
+ });
419
+ files.write(path.join(film, 'clip.json'), { slug, ending: route.ending, seconds: Math.round(total * 100) / 100, shots: shots.map((s, i) => ({ at: Math.round(starts[i] * 100) / 100, ...s })), cues, takes: { game: game.take, end: end.take } });
420
+ if (!keep) fs.rmSync(work, { recursive: true, force: true });
421
+ log(`${outWide} (${duration(outWide).toFixed(1)} s)\n${outTall}\n${stills.join('\n')}${keep ? `\nworking files in ${work}` : ''}`);
422
+ },
423
+ });
424
+
425
+ /** Up to six shots to take a still from: the first explode, story shots spread over the clip, the end, the last one. */
426
+ function stillShots(shots: Shot[]): number[] {
427
+ const story = shots.map((s, i) => (s.source === 'game' && i > 0 ? i : -1)).filter((i) => i >= 0);
428
+ const spread = [0, 1, 2].map((k) => story[Math.round((k * (story.length - 1)) / 2)]).filter((i) => i !== undefined);
429
+ const out = [shots.findIndex((s) => s.source === 'explode'), ...spread, shots.findIndex((s) => s.source === 'end'), shots.length - 1];
430
+ return [...new Set(out.filter((i) => i >= 0))].slice(0, 6);
431
+ }
@@ -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
+ });