@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,97 @@
1
+ // diorama plates <slug> [place...]: plates/<thing>.png for every thing the story lets the hero carry off.
2
+ import fs from 'node:fs';
3
+ import { maskFromPng, maskArea, maskToPng, type Mask } from '../image/mask.ts';
4
+ import { dilate, intersect } from '../image/raster.ts';
5
+ import { defineCommand } from '../registry.ts';
6
+ import { Brief } from '../scene/brief.ts';
7
+ import { Layout } from '../scene/layout.ts';
8
+ import { carryFrom, paintingFor, ran } from '../scene/carry.ts';
9
+ import { changePlate, changeShape, plateFor } from '../scene/plates.ts';
10
+ import { removableThings } from '../story/engine.ts';
11
+ import { Story } from '../story/schema.ts';
12
+ import { WorldFiles } from '../world/files.ts';
13
+
14
+ /** How far from its thing a plate looks at the painting: its grown area, the ring it is matched to, a close view's crop. */
15
+ const PLATE_REACH = 96;
16
+
17
+ defineCommand({
18
+ name: 'plates',
19
+ usage: 'plates <slug> [place...] paint each place without the things the hero can take, and with the things the story changes (needs story.json)',
20
+ async run([slug, ...only]) {
21
+ if (!slug) throw new Error('usage: diorama plates <slug> [place...]');
22
+ const files = new WorldFiles(slug);
23
+ const world = files.world();
24
+ if (!fs.existsSync(files.path('story.json'))) {
25
+ console.log('no story.json: nothing is carried off, no plates needed');
26
+ return;
27
+ }
28
+ const story = Story.parse(files.readJson(files.path('story.json')));
29
+ const removable = removableThings(story);
30
+ const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
31
+ await Promise.all(places.map(async (place) => {
32
+ const at = (...parts: string[]) => files.place(place.key, ...parts);
33
+ const painted = fs.readFileSync(at('painting.png'));
34
+ // After a small repaint a plate the change does not reach is the one made last time (the memo keeps it).
35
+ const carry = await carryFrom(at(), 'plates', painted);
36
+ fs.rmSync(at('plates'), { recursive: true, force: true });
37
+ fs.rmSync(at('changes'), { recursive: true, force: true });
38
+ const keys = removable.filter((r) => r.place === place.key).map((r) => r.thing);
39
+ const changes = story.changes.map((c, i) => ({ ...c, i })).filter((c) => c.place === place.key);
40
+ if (!keys.length && !changes.length) return ran(at(), 'plates', carry?.old ?? painted);
41
+ const notes = (files.readJson(at('masks.json')) as { things: Record<string, { phrase: string }> }).things;
42
+ // A plate is painted around its thing and matched to a ring around it.
43
+ const around = (mask: Mask) => paintingFor(carry, painted, mask, PLATE_REACH);
44
+ // changes/<i>.png: the place once story.changes[i] holds, drawn by the runtime over the painting.
45
+ // Every plate is its own Muse edit: they are painted side by side.
46
+ const changed = Promise.all(changes.map(async (c) => {
47
+ if (!notes[c.thing]) {
48
+ console.log(` ${place.key}: ${c.thing} has no mask, no change plate`);
49
+ return;
50
+ }
51
+ const mask = await maskFromPng(fs.readFileSync(at('masks', `${c.thing}.png`)));
52
+ // What the hero can take keeps its own pixels: Muse redraws all it sees, and a ticket it moved would lie
53
+ // beside the one the player clicks (its outline off), and stay when the hero takes it. Props may be
54
+ // overgrown: a basket that grew a rope may reach over the door beside it.
55
+ const others = await Promise.all(keys.filter((k) => k !== c.thing && fs.existsSync(at('masks', `${k}.png`)))
56
+ .map(async (k) => maskFromPng(fs.readFileSync(at('masks', `${k}.png`)))));
57
+ const plate = await changePlate(files.memo, place, { key: c.thing, phrase: notes[c.thing].phrase, mask }, around(mask), c.looks, others);
58
+ files.write(at('changes', `${c.i}.png`), plate);
59
+ // changes/<i>-shape.png: the thing as the plate draws it, for its hover outline while the change holds.
60
+ const shape = await changeShape(files.memo, place, { key: c.thing, mask }, plate, c.looks).catch((error) => {
61
+ console.log(` ${place.key}: ${c.thing}: no shape for its change (${error instanceof Error ? error.message : error})`);
62
+ return undefined;
63
+ });
64
+ if (shape) files.write(at('changes', `${c.i}-shape.png`), await maskToPng(shape));
65
+ else console.log(` ${place.key}: ${c.thing}: SAM found no clear shape in its change plate; the outline keeps the painted one`);
66
+ console.log(`${place.key}: ${c.thing} once ${c.when.join(', ')}: ${c.looks}`);
67
+ }));
68
+ if (!keys.length) {
69
+ await changed;
70
+ return ran(at(), 'plates', carry?.old ?? painted);
71
+ }
72
+ const layout = Layout.parse(files.readJson(at('layout.json')));
73
+ const brief = Brief.parse(files.readJson(at('brief.json')));
74
+ const floors = await Promise.all(layout.floors.map(async (f) => ({ f, mask: await maskFromPng(fs.readFileSync(at('masks', 'floors', `${f.key}.png`))) })));
75
+ const made = await Promise.all(keys.map(async (key) => {
76
+ if (!notes[key]) {
77
+ console.log(` ${place.key}: ${key} has no mask, no plate`);
78
+ return [];
79
+ }
80
+ const mask = await maskFromPng(fs.readFileSync(at('masks', `${key}.png`)));
81
+ // The floor around it says what to paint where it stood.
82
+ const ring = dilate(mask, 16);
83
+ const under = floors.map(({ f, mask: m }) => ({ f, px: maskArea(intersect(m, ring)) })).sort((a, b) => b.px - a.px)[0];
84
+ const ground = under?.px ? (brief.looks[under.f.key] ?? under.f.is) : 'the ground around it';
85
+ const plate = await plateFor(files.memo, place, { key, phrase: notes[key].phrase, mask }, around(mask), ground);
86
+ files.write(at('plates', `${key}.png`), plate.png);
87
+ console.log(`${place.key}: plate for ${key} (seam ${plate.ringError.toFixed(1)}, stain ${Math.round(plate.stain * 100)}%)${plate.stillThere ? ' (SAM still finds it: check the plate)' : ''}`);
88
+ return [[key, { stillThere: plate.stillThere, iou: Math.round(plate.iou * 100) / 100, ringError: Math.round(plate.ringError * 10) / 10, stain: Math.round(plate.stain * 100) / 100 }] as const];
89
+ }));
90
+ await changed;
91
+ const report = Object.fromEntries(made.flat());
92
+ files.write(at('plates.json'), report);
93
+ ran(at(), 'plates', carry?.old ?? painted);
94
+ }));
95
+ console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
96
+ },
97
+ });
@@ -0,0 +1,192 @@
1
+ // diorama play <slug>: serves the world to a browser page where you walk through its places.
2
+ import fs from 'node:fs';
3
+ import { animationCurrent } from '../scene/animation-source.ts';
4
+ import { registeredFace } from '../scene/faces-data.ts';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { build } from 'esbuild';
9
+ import { Geometry } from '../play/geometry.ts';
10
+ import { previewGeometry } from '../play/preview.ts';
11
+ import type { Layers } from '../play/parallax.ts';
12
+ import { Rig } from '../play/rig.ts';
13
+ import { Reactions } from '../play/reactions.ts';
14
+ import { reactionInput } from './reactions.ts';
15
+ import { reactionSource } from '../scene/reactions.ts';
16
+ import { Weather } from '../play/weather.ts';
17
+ import { glowsOf, type Glow } from '../play/glow.ts';
18
+ import { readBodies } from '../scene/cast.ts';
19
+ import type { ModelInfo } from '../../web/hero3d.ts';
20
+ import { modelProblem } from '../model/glb.ts';
21
+ import type { SpriteInfo } from '../../web/main.ts';
22
+ import { soundData } from '../sound/data.ts';
23
+ import { defineCommand, takeOption } from '../registry.ts';
24
+ import { ItemsInfo } from '../scene/icons.ts';
25
+ import { Layout } from '../scene/layout.ts';
26
+ import { Story } from '../story/schema.ts';
27
+ import { players } from '../world/drawings.ts';
28
+ import { WorldFiles } from '../world/files.ts';
29
+
30
+ /** layers.json as the backdrop step writes it: the same shape the page reads, its files relative to the place. */
31
+ type Flats = Layers;
32
+
33
+ export const WEB = fileURLToPath(new URL('../../web/', import.meta.url));
34
+ const TYPES: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.json': 'application/json', '.mp3': 'audio/mpeg' };
35
+
36
+ /** What the page loads: the world, and per place its geometry (measured, or a preview from the layout). */
37
+ export function worldData(files: WorldFiles) {
38
+ const world = files.world();
39
+ const places: Record<string, { geometry: Geometry | null; painting: string | null; backdrop?: string; layers?: Layers; weather?: Weather; reactions?: Reactions; glows?: Glow[]; issue?: string }> = {};
40
+ for (const place of world.places) {
41
+ const painted = ['painting.png', 'blockin.png'].find((f) => fs.existsSync(files.place(place.key, f)));
42
+ let geometry: Geometry | null = null;
43
+ let issue: string | undefined;
44
+ try {
45
+ if (fs.existsSync(files.place(place.key, 'geometry.json'))) geometry = Geometry.parse(files.readJson(files.place(place.key, 'geometry.json')));
46
+ else if (fs.existsSync(files.place(place.key, 'layout.json'))) geometry = previewGeometry(Layout.parse(files.readJson(files.place(place.key, 'layout.json'))), place);
47
+ else issue = 'no layout.json or geometry.json yet';
48
+ } catch (error) {
49
+ issue = `geometry.json: ${error instanceof Error ? error.message.slice(0, 300) : error}`;
50
+ }
51
+ // backdrop.png: the painting without its standing things; with it the page draws the place as a parallax diorama.
52
+ // Not for a place painted from a photo: the film dissolves the photo into that exact frame, so it stays still.
53
+ const fromPhoto = fs.existsSync(files.place(place.key, 'photo.png'));
54
+ const backdrop = painted === 'painting.png' && !fromPhoto && fs.existsSync(files.place(place.key, 'backdrop.png')) ? `places/${place.key}/backdrop.png` : undefined;
55
+ // layers.json: the far view as flats (sky, then farthest to nearest), from the same backdrop step.
56
+ const layersFile = files.place(place.key, 'layers.json');
57
+ const flats = backdrop && fs.existsSync(layersFile) ? (files.readJson(layersFile) as Flats) : undefined;
58
+ const rel = (f: string) => `places/${place.key}/${f}`;
59
+ const layers: Layers | undefined = flats && {
60
+ sky: flats.sky ? rel(flats.sky) : null,
61
+ flats: flats.flats.map((f) => ({ file: rel(f.file), box: f.box })),
62
+ ...(flats.water && { water: { tex: rel(flats.water.tex), mask: rel(flats.water.mask), box: flats.water.box, flow: flats.water.flow } }),
63
+ ...(flats.mist && { mist: { ...flats.mist, tex: rel(flats.mist.tex), mask: rel(flats.mist.mask) } }),
64
+ // Smoke, rocking boats, waterfalls, animals and clouds: each a file or a few beside the place.
65
+ ...(flats.smoke?.length && { smoke: flats.smoke.map((f) => ({ ...f, file: rel(f.file) })) }),
66
+ ...(flats.floats?.length && { floats: flats.floats.map((f) => ({ ...f, file: rel(f.file) })) }),
67
+ ...(flats.falls?.length && { falls: flats.falls.map((f) => ({ ...f, file: rel(f.file) })) }),
68
+ ...(flats.critters?.length && { critters: flats.critters.map((f) => ({ ...f, file: rel(f.file) })) }),
69
+ ...(flats.clouds && { clouds: { ...flats.clouds, clip: rel(flats.clouds.clip), ...(flats.clouds.keep && { keep: rel(flats.clouds.keep) }), bands: flats.clouds.bands.map((b) => ({ ...b, file: rel(b.file) })) } }),
70
+ };
71
+ // weather.json (diorama weather): what falls here and where water drips, for the page to animate.
72
+ const weatherFile = files.place(place.key, 'weather.json');
73
+ const read = painted === 'painting.png' && fs.existsSync(weatherFile) ? Weather.safeParse(files.readJson(weatherFile)) : undefined;
74
+ const weather = read?.success ? { ...read.data, ...(read.data.ripples && { ripples: rel(read.data.ripples) }) } : undefined;
75
+ // layout.lights: the painted lights, to breathe, flicker, twinkle or turn their beam (src/play/glow.ts).
76
+ const layoutFile = files.place(place.key, 'layout.json');
77
+ const lights = painted === 'painting.png' && fs.existsSync(layoutFile) ? Layout.safeParse(files.readJson(layoutFile)) : undefined;
78
+ // On the lights as painted when derive found them (geometry.glows); an older derive has only the planned points.
79
+ const paintedAt = geometry?.glows;
80
+ const sources = lights?.success ? (paintedAt ? lights.data.lights.flatMap((l) => { const p = paintedAt.find((g) => g.key === l.key); return p ? [{ ...l, x: p.x, y: p.y }] : []; }) : lights.data.lights) : [];
81
+ const glows = sources.length ? glowsOf(sources) : undefined;
82
+ let reactions: Reactions | undefined;
83
+ const reactionsFile = files.place(place.key, 'reactions.json');
84
+ if (fs.existsSync(reactionsFile)) {
85
+ try {
86
+ const rules = Reactions.parse(files.readJson(reactionsFile));
87
+ if (rules.source === reactionSource(reactionInput(files, place.key))) reactions = rules;
88
+ else console.warn(`${place.key}: reactions stale; rerun reactions`);
89
+ } catch (e) { console.warn(`${place.key}: reactions unavailable: ${e instanceof Error ? e.message : String(e)}`); }
90
+ }
91
+ places[place.key] = { geometry, painting: painted ? `places/${place.key}/${painted}` : null, ...(backdrop && { backdrop }), ...(layers && { layers }), ...(weather && { weather }), ...(reactions && { reactions }), ...(glows && { glows }), issue };
92
+ }
93
+ const castDir = files.path('cast');
94
+ // cast/figures.json (diorama cast): metres each one stands (a grown-up 1.62) and whether upright; one that
95
+ // isn't (a cat) is drawn as painted, whatever rig, model or sheets an older build left.
96
+ const bodies = readBodies(files, fs.existsSync);
97
+ const cast = Object.fromEntries((fs.existsSync(castDir) ? fs.readdirSync(castDir) : [])
98
+ .filter((f) => f.endsWith('.png') && !f.endsWith('-painting.png'))
99
+ .map((f) => {
100
+ const key = f.slice(0, -4);
101
+ // A walk that measured torn when it was made (rig.json check) is left out: the painted figure slides as one piece.
102
+ const rig = animationCurrent(files,key,'rig') && fs.existsSync(files.path('cast', key, 'rig.json')) ? Rig.parse(files.readJson(files.path('cast', key, 'rig.json'))) : undefined;
103
+ // cast/<key>/model/model.json (diorama model): a rigged 3D figure the page draws instead of the 2D rig.
104
+ // A flat one (a card TRELLIS made of a flat drawing, kept before the model step checked) is left out, and so
105
+ // are the sheets painted over its guides: the character walks with its 2D rig.
106
+ const modelOk = animationCurrent(files,key,'model') && fs.existsSync(files.path('cast', key, 'model', 'model.json')) && !modelProblem(files.path('cast', key, 'model'));
107
+ const model = modelOk ? { dir: `cast/${key}/model`, info: files.readJson(files.path('cast', key, 'model', 'model.json')) as ModelInfo } : undefined;
108
+ // cast/<key>/sprites/sprites.json (diorama sprites): painted walk sheets, preferred over the 3D figure.
109
+ const sprites = modelOk && animationCurrent(files,key,'sprites') && fs.existsSync(files.path('cast', key, 'sprites', 'sprites.json')) ? { dir: `cast/${key}/sprites`, info: files.readJson(files.path('cast', key, 'sprites', 'sprites.json')) as SpriteInfo } : undefined;
110
+ const face = registeredFace(files, key);
111
+ const body = bodies[key];
112
+ // Sheets rendered straight from the model's UV texture (model-*.png, no Muse paint) are a blurry stand-in
113
+ // with the rig's A-pose arms: a 2D walk bent from the character's own painting, when it passed its check,
114
+ // looks like the place it walks in and wins over them. Sheets Muse painted still win over the 2D walk.
115
+ const rendered = sprites && Object.values(sprites.info.facings ?? {}).every((f) => f.sheet.startsWith('model-'));
116
+ const ownWalk = rig?.sheet && rig.check?.ok !== false;
117
+ const sheets = rendered && ownWalk && process.env.DIORAMA_UV_SPRITES !== '1' ? undefined : sprites;
118
+ const walks = body?.upright !== false;
119
+ // A creature has no legs for a 2D rig, but its own 3D model (no skeleton, model.json creature) hops and squashes.
120
+ const creatureModel = model?.info.creature ? model : undefined;
121
+ return [key, { image: `cast/${f}`, ...(face && { face }), ...(body && { height: body.height }), ...(!walks && { upright: false }), ...(walks && rig && rig.check?.ok !== false ? { rig, parts: `cast/${key}` } : {}), ...(walks ? model && { model } : creatureModel && { model: creatureModel }), ...(walks && sheets && { sprites: sheets }) }];
122
+ }));
123
+ const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
124
+ // changes/<i>.png per place: how story.changes[i] looks once it holds.
125
+ const changes: Record<string, string> = {};
126
+ // changes/<i>-shape.png: the changed thing's shape in it, which its hover outline follows while the change holds.
127
+ const changeShapes: Record<string, string> = {};
128
+ (story?.changes ?? []).forEach((c, i) => {
129
+ const file = `places/${c.place}/changes/${i}.png`;
130
+ if (fs.existsSync(files.path(file))) changes[i] = file;
131
+ const shape = `places/${c.place}/changes/${i}-shape.png`;
132
+ if (changes[i] && fs.existsSync(files.path(shape))) changeShapes[i] = shape;
133
+ });
134
+ // sound.json + sound/: what the world sounds like (diorama sound); absent, the page plays silent.
135
+ const sound = soundData(files);
136
+ // items/items.json (diorama icons): a painted icon per story item; without it the bag shows names.
137
+ const itemsFile = files.path('items', 'items.json');
138
+ const items = fs.existsSync(itemsFile)
139
+ ? Object.fromEntries(Object.entries(ItemsInfo.parse(files.readJson(itemsFile))).filter(([, i]) => fs.existsSync(files.path(i.icon))).map(([k, i]) => [k, { icon: i.icon }]))
140
+ : undefined;
141
+ // The heroes of more gamepads (drawings marked player), in drawing order: pad 2 plays the first.
142
+ const others = players(files).map((p) => p.key).filter((k) => cast[k]);
143
+ return { slug: files.slug, world, places, cast, story, changes, changeShapes, ...(sound && { sound }), ...(items && { items }), ...(others.length && { players: others }) };
144
+ }
145
+
146
+ export async function bundle(forShip = false): Promise<string> {
147
+ const out = await build({ entryPoints: [path.join(WEB, 'main.ts')], bundle: true, format: 'esm', write: false, target: 'es2022', ...(forShip ? { minify: true } : { sourcemap: 'inline' }) });
148
+ return out.outputFiles[0].text;
149
+ }
150
+
151
+ /** Serves the page and the world's files on localhost; resolves once listening. */
152
+ export async function serveWorld(files: WorldFiles, port: number): Promise<http.Server> {
153
+ const server = http.createServer(async (req, res) => {
154
+ const url = new URL(req.url ?? '/', 'http://localhost');
155
+ const send = (status: number, type: string, body: string | Buffer) => {
156
+ res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
157
+ res.end(body);
158
+ };
159
+ try {
160
+ if (url.pathname === '/') return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(WEB, 'index.html')));
161
+ if (url.pathname === '/prototype/conductor-lipsync') return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(WEB, '../demo/conductor-lipsync/index.html')));
162
+ if (url.pathname === '/main.js') return send(200, 'text/javascript; charset=utf-8', await bundle());
163
+ if (url.pathname === '/world-data.json') return send(200, 'application/json', JSON.stringify(worldData(files)));
164
+ if (url.pathname.startsWith('/files/')) {
165
+ const file = path.resolve(files.dir, decodeURIComponent(url.pathname.slice('/files/'.length)));
166
+ if (!file.startsWith(files.dir + path.sep) || !fs.existsSync(file)) return send(404, 'text/plain', 'not found');
167
+ return send(200, TYPES[path.extname(file)] ?? 'application/octet-stream', fs.readFileSync(file));
168
+ }
169
+ send(404, 'text/plain', 'not found');
170
+ } catch (error) {
171
+ send(500, 'text/plain', String(error instanceof Error ? error.stack : error));
172
+ }
173
+ });
174
+ await new Promise<void>((resolve) => server.listen(port, resolve));
175
+ return server;
176
+ }
177
+
178
+ defineCommand({
179
+ name: 'play',
180
+ usage: 'play <slug> serve the world at http://localhost:4173 to walk through it [--port N]',
181
+ async run(args) {
182
+ const port = Number(takeOption(args, 'port', '4173'));
183
+ const [slug] = args;
184
+ if (!slug) throw new Error('usage: diorama play <slug>');
185
+ const files = new WorldFiles(slug);
186
+ files.world();
187
+
188
+ await serveWorld(files, port);
189
+ console.log(`play ${slug}: http://localhost:${port} (g: grid and occluders, Ctrl+C to stop)`);
190
+ await new Promise(() => {});
191
+ },
192
+ });
@@ -0,0 +1,111 @@
1
+ // diorama playthrough <slug> [ending...]: the bot plays the shortest route to each ending in the real page,
2
+ // fails if an ending is not reached, and records each run (playthrough/<ending>.webm) when --video is given,
3
+ // with the page's own sound laid under it (playthrough/<ending>.mp4) when the world has sound.
4
+ import { execFileSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import type { Page } from 'playwright';
8
+ import { playRoute } from '../qa/route.ts';
9
+ import { defineCommand, takeOption } from '../registry.ts';
10
+ import { checkStory } from '../story/check.ts';
11
+ import type { Action } from '../story/engine.ts';
12
+ import { WorldFiles } from '../world/files.ts';
13
+ import { chromium } from './explore.ts';
14
+ import { serveWorld } from './play.ts';
15
+
16
+ type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
17
+
18
+ /** A story action as what a player does on the page: click the thing, or pick an answer. */
19
+ export function toStep(action: Action): Step {
20
+ switch (action.kind) {
21
+ case 'go': return { use: action.via };
22
+ case 'talk': return { use: action.npc };
23
+ case 'do': return { use: action.thing, verb: action.verb, ...(action.with ? { with: action.with } : {}) };
24
+ default: return { act: action };
25
+ }
26
+ }
27
+
28
+ /** The run's video with the page's sound under it, as mp4; the sound started `offset` s into the video. */
29
+ function withSound(webm: string, audio: Buffer, offset: number): string {
30
+ const sound = webm.replace(/\.webm$/, '.sound.webm');
31
+ const mp4 = webm.replace(/\.webm$/, '.mp4');
32
+ fs.writeFileSync(sound, audio);
33
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', webm, '-itsoffset', offset.toFixed(3), '-i', sound, '-map', '0:v', '-map', '1:a',
34
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-c:a', 'aac', '-b:a', '192k', mp4]);
35
+ return mp4;
36
+ }
37
+
38
+ type Run = { ending: string; reached: string | null; clicks: number; seconds: number; frames: number; video?: string; error?: string };
39
+
40
+ defineCommand({
41
+ name: 'playthrough',
42
+ usage: 'playthrough <slug> [ending...] play the route to each ending in the browser and check it is reached [--video] [--query "parallax=1&track=1&voices=npc"] [--lead S] [--port N]',
43
+ async run(args) {
44
+ const video = args.includes('--video');
45
+ const port = Number(takeOption(args, 'port', '4193'));
46
+ // Extra page options, e.g. parallax=1&track=1, so a recording looks like the game people play.
47
+ const query = takeOption(args, 'query', '');
48
+ // Seconds of the first place before the first click, so the intro line is heard in full.
49
+ const lead = Number(takeOption(args, 'lead', '2.5'));
50
+ const [slug, ...only] = args.filter((a) => !a.startsWith('--'));
51
+ if (!slug) throw new Error('usage: diorama playthrough <slug> [ending...] [--video]');
52
+ const files = new WorldFiles(slug);
53
+ const world = files.world();
54
+ // Routes come from the story as it is now, never from an old routes.json.
55
+ const report = checkStory(world, files.readJson(files.path('story.json')));
56
+ if (report.issues.length) throw new Error(`story.json does not check out:\n- ${report.issues.join('\n- ')}`);
57
+ const routes = report.routes.filter((r) => only.length === 0 || only.includes(r.ending));
58
+ const outDir = files.path('playthrough');
59
+ // Only this run's endings start over, so recording one ending keeps the others' videos.
60
+ for (const r of routes) for (const ext of ['.webm', '.sound.webm', '.mp4']) fs.rmSync(path.join(outDir, `${r.ending}${ext}`), { force: true });
61
+
62
+ const server = await serveWorld(files, port);
63
+ // Headless pages may play sound without a click, so the recorder hears the game from the first frame.
64
+ const browser = await (await chromium()).launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
65
+ const runs: Run[] = [];
66
+ // Studio counts the endings off these lines: "1/3 ...", then ok or FAIL for each.
67
+ console.log(`playing ${routes.length} ending${routes.length === 1 ? '' : 's'}: ${routes.map((r) => r.ending).join(', ')}`);
68
+ try {
69
+ for (const [i, route] of routes.entries()) {
70
+ console.log(`${i + 1}/${routes.length} playing to ${route.ending}`);
71
+ const opened = Date.now();
72
+ const page = (await browser.newPage({
73
+ viewport: { width: 1920, height: 1080 },
74
+ ...(video ? { recordVideo: { dir: outDir, size: { width: 1920, height: 1080 } } } : {}),
75
+ })) as Page & { close(): Promise<void>; video(): { path(): Promise<string> } | null };
76
+ page.on('pageerror', (e) => console.log(` page error: ${e.message}`));
77
+ await page.goto(`http://localhost:${port}/?capture=1&cursor=0${query ? `&${query}` : ''}`);
78
+ await page.waitForFunction('window.diorama && window.diorama.place', undefined, { timeout: 30000 });
79
+ const steps: Step[] = [{ wait: lead * 1000 }, ...route.actions.map(toStep), { wait: 2500 }];
80
+ const started = Date.now();
81
+ const sound = video && (await page.evaluate<boolean>('!!(window.diorama.sound && window.diorama.data.sound && (window.diorama.sound.record(), true))'));
82
+ const run: Run = { ending: route.ending, reached: null, clicks: route.actions.length, seconds: 0, frames: 0 };
83
+ try {
84
+ const result = await playRoute(page, steps);
85
+ Object.assign(run, { reached: result.ending, frames: result.frames, seconds: Math.round(result.time * 10) / 10 });
86
+ } catch (error) {
87
+ run.error = (error as Error).message.split('\n')[0];
88
+ }
89
+ const audio = sound ? Buffer.from(await page.evaluate<string>('window.diorama.sound.stopRecording()'), 'base64') : undefined;
90
+ await page.close();
91
+ const recorded = video ? await page.video()?.path() : undefined;
92
+ if (recorded) {
93
+ run.video = path.join(outDir, `${route.ending}.webm`);
94
+ fs.renameSync(recorded, run.video);
95
+ if (audio?.length) run.video = withSound(run.video, audio, (started - opened) / 1000);
96
+ }
97
+ runs.push(run);
98
+ const ok = run.reached === route.ending;
99
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${route.ending}: ${run.clicks} clicks, ${run.seconds} s game time (${((Date.now() - started) / 1000).toFixed(0)} s wall)${run.error ? ` · ${run.error}` : ''}${!ok && !run.error ? ` · reached ${run.reached ?? 'no ending'}` : ''}`);
100
+ }
101
+ } finally {
102
+ await browser.close();
103
+ server.close();
104
+ }
105
+ const reportFile = path.join(outDir, 'report.json');
106
+ const earlier = fs.existsSync(reportFile) ? (files.readJson(reportFile) as Run[]).filter((r) => !runs.some((n) => n.ending === r.ending)) : [];
107
+ files.write(reportFile, [...earlier, ...runs]);
108
+ const failed = runs.filter((r) => r.reached !== r.ending);
109
+ if (failed.length) throw new Error(`${failed.length} of ${runs.length} ending(s) not reached in the page`);
110
+ },
111
+ });
@@ -0,0 +1,78 @@
1
+ // diorama publish <slug>: exports the world onto the repo's gh-pages branch and pushes it, so anyone can play it at a link.
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { defineCommand, takeOption } from '../registry.ts';
6
+ import { exportWorld } from './export.ts';
7
+
8
+ /** https://<owner>.github.io/<repo>/ for a GitHub remote URL (https or ssh), or null for any other host. */
9
+ export function pagesUrl(remote: string): string | null {
10
+ const m = remote.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
11
+ if (!m) return null;
12
+ const [, owner, repo] = m;
13
+ return repo.toLowerCase() === `${owner.toLowerCase()}.github.io` ? `https://${repo}/` : `https://${owner}.github.io/${repo}/`;
14
+ }
15
+
16
+ /** The front page of the published site: one link per world folder that holds a world-data.json. */
17
+ export function indexPage(worlds: { slug: string; title: string }[]): string {
18
+ const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
19
+ const items = worlds.map((w) => `<li><a href="./${encodeURIComponent(w.slug)}/">${esc(w.title)}</a></li>`).join('\n');
20
+ return `<!doctype html>
21
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Diorama worlds</title>
22
+ <style>body{margin:0;padding:40px 24px;background:#0b0b0e;color:#e8e2d6;font:18px/1.5 Georgia,serif}main{max-width:640px;margin:auto}a{color:#ffd37a}li{margin:8px 0}</style>
23
+ </head><body><main><h1>Diorama worlds</h1><p>Painted point-and-click places made with Meta Muse and SAM 3.1. Click one to play in the browser.</p>
24
+ <ul>
25
+ ${items}
26
+ </ul><p><a href="https://github.com/gtanczyk/diorama">Source</a></p></main></body></html>
27
+ `;
28
+ }
29
+
30
+ defineCommand({
31
+ name: 'publish',
32
+ usage: 'publish <slug> [--remote origin] [--branch gh-pages] [--no-push] put the world on GitHub Pages and print its link',
33
+ async run(args) {
34
+ const remote = takeOption(args, 'remote', 'origin');
35
+ const branch = takeOption(args, 'branch', 'gh-pages');
36
+ const push = !args.includes('--no-push');
37
+ const [slug] = args.filter((a) => a !== '--no-push');
38
+ if (!slug) throw new Error('usage: diorama publish <slug> [--remote origin] [--branch gh-pages] [--no-push]');
39
+ const git = (cwd: string, ...a: string[]) => execFileSync('git', a, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
40
+ const top = git(process.cwd(), 'rev-parse', '--show-toplevel');
41
+
42
+ // A worktree of the pages branch under .diorama/, so the checkout you work in never switches branch.
43
+ const site = path.join(top, '.diorama', 'pages');
44
+ if (fs.existsSync(site)) git(top, 'worktree', 'remove', '--force', site);
45
+ git(top, 'worktree', 'prune');
46
+ const onRemote = git(top, 'ls-remote', '--heads', remote, branch) !== '';
47
+ if (onRemote) {
48
+ git(top, 'fetch', remote, branch);
49
+ git(top, 'worktree', 'add', '-B', branch, site, 'FETCH_HEAD');
50
+ } else if (git(top, 'branch', '--list', branch) !== '') {
51
+ git(top, 'worktree', 'add', site, branch);
52
+ } else {
53
+ git(top, 'worktree', 'add', '--detach', site);
54
+ git(site, 'checkout', '--orphan', branch);
55
+ git(site, 'rm', '-rfq', '--ignore-unmatch', '.');
56
+ }
57
+
58
+ await exportWorld(slug, path.join(site, slug));
59
+ fs.writeFileSync(path.join(site, '.nojekyll'), '');
60
+ const worlds = fs.readdirSync(site)
61
+ .filter((d) => fs.existsSync(path.join(site, d, 'world-data.json')))
62
+ .map((d) => ({ slug: d, title: JSON.parse(fs.readFileSync(path.join(site, d, 'world-data.json'), 'utf8')).world?.title ?? d }));
63
+ fs.writeFileSync(path.join(site, 'index.html'), indexPage(worlds));
64
+
65
+ git(site, 'add', '-A');
66
+ if (git(site, 'status', '--porcelain') === '') console.log(`publish ${slug}: nothing changed`);
67
+ else git(site, 'commit', '-qm', `publish ${slug}`);
68
+ if (push) git(site, 'push', remote, branch);
69
+
70
+ const base = pagesUrl(git(top, 'remote', 'get-url', remote));
71
+ console.log(`publish ${slug}: ${push ? 'pushed' : 'committed, not pushed'} to ${remote}/${branch}`);
72
+ if (base) {
73
+ console.log(` play: ${base}${slug}/`);
74
+ console.log(` all worlds: ${base}`);
75
+ if (!onRemote) console.log(` first publish: turn on Settings > Pages > Deploy from a branch > ${branch} once; the link works a minute after.`);
76
+ }
77
+ },
78
+ });
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import { defineCommand } from '../registry.ts';
3
+ import { WorldFiles } from '../world/files.ts';
4
+ import { Geometry } from '../play/geometry.ts';
5
+ import { Reactions } from '../play/reactions.ts';
6
+ import { Layout } from '../scene/layout.ts';
7
+ import { Story } from '../story/schema.ts';
8
+ import { planReactions, reactionSource, type ReactionInput } from '../scene/reactions.ts';
9
+
10
+ export function reactionInput(files: WorldFiles, key: string): ReactionInput {
11
+ const world = files.world(), place = world.places.find((p) => p.key === key);
12
+ if (!place) throw new Error(`unknown place ${key}`);
13
+ const read = (file: string) => fs.existsSync(file) ? files.readJson(file) : undefined;
14
+ const wish = read(files.place(key, 'reactions-wish.json')) as { hint?: string } | undefined;
15
+ const story = read(files.path('story.json'));
16
+ return { world, place, layout: Layout.parse(files.readJson(files.place(key, 'layout.json'))),
17
+ geometry: Geometry.parse(files.readJson(files.place(key, 'geometry.json'))),
18
+ story: story ? Story.parse(story) : null, weather: read(files.place(key, 'weather.json')), hint: wish?.hint,
19
+ painting: fs.readFileSync(files.place(key, 'painting.png')) };
20
+ }
21
+
22
+ defineCommand({
23
+ name: 'reactions',
24
+ usage: 'reactions <slug> [place...] choose and bind subtle step, area-entry and object reactions automatically',
25
+ async run([slug, ...only]) {
26
+ if (!slug) throw new Error('usage: reactions <slug> [place...]');
27
+ const files = new WorldFiles(slug), world = files.world();
28
+ for (const key of only) if (!world.places.some((p) => p.key === key)) throw new Error(`unknown place ${key}`);
29
+ await Promise.all(world.places.filter((p) => !only.length || only.includes(p.key)).map(async (p) => {
30
+ const input = reactionInput(files, p.key), out = files.place(p.key, 'reactions.json');
31
+ const was = fs.existsSync(out) ? Reactions.safeParse(files.readJson(out)) : undefined;
32
+ if (was?.success && was.data.source === reactionSource(input)) { console.log(`${p.key}: reactions unchanged`); return; }
33
+ const { plan, reactions } = await planReactions(files.memo, input);
34
+ files.write(files.place(p.key, 'reactions-plan.json'), plan);
35
+ // Replace only after successful binding: a failed model call preserves the last good plan.
36
+ files.write(out, reactions);
37
+ console.log(`${p.key}: reactions — ${reactions.areas.length} areas, ${reactions.interactions.length} interactions. ${reactions.scene}`);
38
+ }));
39
+ },
40
+ });
@@ -0,0 +1,46 @@
1
+ // diorama reel <slug> [place]: the build-up of a place as a video, one beat per step (film/reel-<place>.mp4).
2
+ import { execFileSync } from 'node:child_process';
3
+ import { placeBeats, SCREEN } from '../film/reel.ts';
4
+ import { defineCommand, takeOption } from '../registry.ts';
5
+ import { WorldFiles } from '../world/files.ts';
6
+
7
+ defineCommand({
8
+ name: 'reel',
9
+ usage: 'reel <slug> [place] the build-up of a place as a video, a beat per step (film/reel-<place>.mp4) [--beat 7] [--fade 0.6] [--clean: no captions]',
10
+ async run(args) {
11
+ const beat = Number(takeOption(args, 'beat', '7'));
12
+ const fade = Number(takeOption(args, 'fade', '0.6'));
13
+ const clean = args.includes('--clean');
14
+ const [slug, only] = args.filter((a) => a !== '--clean');
15
+ if (!slug) throw new Error('usage: diorama reel <slug> [place]');
16
+ const files = new WorldFiles(slug);
17
+ const placeKey = only ?? files.world().start;
18
+ const beats = await placeBeats(files, placeKey, clean);
19
+ if (beats.length < 2) throw new Error(`${placeKey} has ${beats.length} step(s) to show; run diorama build first`);
20
+
21
+ const name = `reel-${placeKey}${clean ? '-clean' : ''}`;
22
+ const stills = beats.map((b, i) => {
23
+ const file = files.path('film', name, `${String(i + 1).padStart(2, '0')}-${b.key}.png`);
24
+ files.write(file, b.still);
25
+ return file;
26
+ });
27
+ // Each still holds for one beat; a transition overlaps the end of one beat with the start of the next,
28
+ // so beat i starts at i * beat seconds and the film's cuts land on the click track.
29
+ const inputs = stills.flatMap((f, i) => ['-loop', '1', '-t', String(beat + (i < stills.length - 1 ? fade : 0)), '-i', f]);
30
+ let chain = '';
31
+ let last = '[0:v]';
32
+ for (let i = 1; i < stills.length; i++) {
33
+ const out = i === stills.length - 1 ? '[out]' : `[x${i}]`;
34
+ chain += `${last}[${i}:v]xfade=transition=${beats[i].enter}:duration=${fade}:offset=${i * beat}${out};`;
35
+ last = out;
36
+ }
37
+ const mp4 = files.path('film', `${name}.mp4`);
38
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, '-filter_complex', `${chain}[out]fps=30,format=yuv420p[v]`, '-map', '[v]',
39
+ '-s', `${SCREEN.width}x${SCREEN.height}`, '-c:v', 'libx264', '-crf', '20', mp4], { stdio: 'inherit' });
40
+ files.write(files.path('film', `${name}.json`), {
41
+ place: placeKey, beat, fade,
42
+ beats: beats.map((b, i) => ({ key: b.key, title: b.title, by: b.by, start: i * beat, still: stills[i] })),
43
+ });
44
+ console.log(`${mp4}: ${beats.length} beats (${beats.map((b) => b.key).join(' > ')}), ${Math.round(beats.length * beat * 10) / 10}s`);
45
+ },
46
+ });