@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,74 @@
1
+ // diorama doctor: one live call per model, so a bad key or a changed API shows up before a build.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { maskArea, maskToPng } from '../image/mask.ts';
5
+ import { Memo } from '../meta/memo.ts';
6
+ import { museEdit, musePaint } from '../meta/muse.ts';
7
+ import { samFind } from '../meta/sam.ts';
8
+ import { askSparkJson } from '../meta/spark.ts';
9
+ import { defineCommand } from '../registry.ts';
10
+
11
+ defineCommand({
12
+ name: 'doctor',
13
+ usage: 'doctor [dir] [--now] check the API key with one call to Spark, Muse paint, Muse edit and SAM 3.1; --now skips the memo and says if the network and Meta are fast enough for a live build',
14
+ async run(args) {
15
+ const nowAt = args.indexOf('--now');
16
+ if (nowAt >= 0) args.splice(nowAt, 1);
17
+ const [dir = '.diorama/doctor'] = args;
18
+ const out = path.resolve(dir);
19
+ fs.mkdirSync(out, { recursive: true });
20
+ // A memo hit takes no time, so --now asks afresh to measure this network and Meta as they are.
21
+ const memo = new Memo(path.join(out, nowAt >= 0 ? `memo-${Date.now()}` : 'memo'));
22
+ let failed = 0;
23
+ const took: Record<string, number> = {};
24
+ async function check<T>(name: string, run: () => Promise<T>, describe: (v: T) => string): Promise<T | undefined> {
25
+ const started = Date.now();
26
+ try {
27
+ const value = await run();
28
+ took[name] = Date.now() - started;
29
+ console.log(` ok ${name.padEnd(11)}${`${Date.now() - started} ms`.padStart(9)} ${describe(value)}`);
30
+ return value;
31
+ } catch (error) {
32
+ failed++;
33
+ console.log(` FAIL ${name.padEnd(11)}${error instanceof Error ? error.message : error}`);
34
+ }
35
+ }
36
+
37
+ await check('spark', () => askSparkJson<{ thing: string }>(memo, {
38
+ label: 'doctor',
39
+ prompt: 'Name one thing a traveller might find at a crossroads inn. Reply as JSON: {"thing": "..."}',
40
+ }), (a) => `"${a.thing}"`);
41
+
42
+ const painting = await check('muse paint', () => musePaint(memo, {
43
+ label: 'doctor',
44
+ prompt: 'A painted background for a 2D adventure game: the yard of a crossroads inn at dusk, a cart with a wooden barrel, a well, lanterns on the porch. Gouache, no people, no text.',
45
+ }), (png) => `${Math.round(png.length / 1024)} KB`);
46
+ if (!painting) return void process.exit(1);
47
+ fs.writeFileSync(path.join(out, 'paint.png'), painting);
48
+
49
+ const edited = await check('muse edit', () => museEdit(memo, {
50
+ label: 'doctor',
51
+ images: [painting],
52
+ prompt: 'The same yard on a snowy winter night. Keep the composition exactly.',
53
+ }), (png) => `${Math.round(png.length / 1024)} KB`);
54
+ if (edited) fs.writeFileSync(path.join(out, 'edit.png'), edited);
55
+
56
+ const found = await check('sam', () => samFind(memo, { label: 'doctor', image: painting, prompt: 'barrel' }),
57
+ (f) => `${f.length} barrel(s), ${f.reduce((s, x) => s + maskArea(x.mask), 0)} px`);
58
+ if (found?.[0]) fs.writeFileSync(path.join(out, 'sam-barrel.png'), await maskToPng(found[0].mask));
59
+
60
+ if (nowAt >= 0) console.log(`\n ${liveVerdict(took)}`);
61
+ console.log(`\n sent: spark ${memo.sent.spark}, muse ${memo.sent.muse}, sam ${memo.sent.sam} · files in ${out}`);
62
+ if (failed) process.exit(1);
63
+ },
64
+ });
65
+
66
+ /** Slower than this, one call is a sign of a slow network or a busy Meta: a 25-drawing build took 45 min at 3x these. */
67
+ const SLOW_MS: Record<string, number> = { spark: 30_000, 'muse paint': 60_000, 'muse edit': 60_000, sam: 20_000 };
68
+
69
+ /** Whether a build in front of people will finish in time, from one call to each model. */
70
+ export function liveVerdict(took: Record<string, number>): string {
71
+ const slow = Object.entries(SLOW_MS).filter(([name, limit]) => (took[name] ?? Infinity) > limit).map(([name]) => name);
72
+ if (!slow.length) return 'fast enough for a live build (3 places in about 15 min)';
73
+ return `SLOW: ${slow.join(', ')}. A live build may take 30+ min: play a world built beforehand, or build with fewer drawings and places`;
74
+ }
@@ -0,0 +1,123 @@
1
+ // diorama explore <slug> [place...]: walks the hero over every place in the real play page and
2
+ // reports what looks wrong (explore/ per place: report.json, heatmap.png, sheet.png).
3
+ // --fix closes the cells around bad spots in geometry.json; --no-judge skips the Spark juror.
4
+ import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
6
+ import { Geometry } from '../play/geometry.ts';
7
+ import { classify, contactSheet, fixGrid, heatmap, ISSUES, judge, positions, type Checked } from '../qa/explore.ts';
8
+ import { CROP_JS, PROBE_JS, type ProbeHit } from '../qa/probe.ts';
9
+ import { defineCommand, takeOption } from '../registry.ts';
10
+ import { WorldFiles } from '../world/files.ts';
11
+ import { serveWorld } from './play.ts';
12
+
13
+ export type Browser = { newPage(o?: object): Promise<Page>; close(): Promise<void> };
14
+ export type Page = {
15
+ goto(url: string): Promise<unknown>;
16
+ waitForFunction(js: string, arg?: unknown, o?: object): Promise<unknown>;
17
+ evaluate<T>(js: string, arg?: unknown): Promise<T>;
18
+ on(event: 'pageerror' | 'console', fn: (e: { message?: string; text?: () => string; type?: () => string }) => void): void;
19
+ };
20
+
21
+ /** Playwright ships with diorama; its Chromium is a separate download (diorama browsers). */
22
+ export async function chromium(): Promise<{ launch(o?: object): Promise<Browser> }> {
23
+ try {
24
+ return ((await import('playwright' as string)) as { chromium: never }).chromium;
25
+ } catch {
26
+ try {
27
+ return (createRequire(import.meta.url)('playwright') as { chromium: never }).chromium;
28
+ } catch {
29
+ throw new Error('this command needs Playwright with Chromium: diorama browsers');
30
+ }
31
+ }
32
+ }
33
+
34
+ /** Runs a page function given as source text, so the bundler adds nothing it can't see. */
35
+ const call = <T>(page: Page, fn: string, arg: unknown) => page.evaluate<T>(`(${fn})(${JSON.stringify(arg)})`);
36
+
37
+ defineCommand({
38
+ name: 'explore',
39
+ usage: 'explore <slug> [place...] walk the hero over each place and check every frame [--fix] [--no-judge] [--port N]',
40
+ async run(args) {
41
+ const fix = args.includes('--fix');
42
+ const noJudge = args.includes('--no-judge');
43
+ const port = Number(takeOption(args, 'port', '4183'));
44
+ const [slug, ...only] = args.filter((a) => !a.startsWith('--'));
45
+ if (!slug) throw new Error('usage: diorama explore <slug> [place...] [--fix] [--no-judge]');
46
+ const files = new WorldFiles(slug);
47
+ const world = files.world();
48
+ const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
49
+
50
+ const server = await serveWorld(files, port);
51
+ const browser = await (await chromium()).launch();
52
+ try {
53
+ const page = await browser.newPage({ viewport: { width: 1280, height: 960 } });
54
+ page.on('pageerror', (e) => console.log(` page error: ${e.message}`));
55
+ for (const place of places) {
56
+ const at = (...parts: string[]) => files.place(place.key, ...parts);
57
+ if (!fs.existsSync(at('geometry.json'))) {
58
+ console.log(`${place.key}: no geometry.json, skipped`);
59
+ continue;
60
+ }
61
+ const g = Geometry.parse(files.readJson(at('geometry.json')));
62
+ await page.goto(`http://localhost:${port}/?capture=1&place=${place.key}`);
63
+ await page.waitForFunction(`window.diorama && window.diorama.place && window.diorama.place.key === ${JSON.stringify(place.key)}`, undefined, { timeout: 30000 });
64
+
65
+ const spots = positions(g);
66
+ const started = Date.now();
67
+ const hits: ProbeHit[] = [];
68
+ for (let i = 0; i < spots.length; i += 100) hits.push(...(await call<ProbeHit[]>(page, PROBE_JS, { placeKey: place.key, positions: spots.slice(i, i + 100) })));
69
+ const checked = hits.map(classify);
70
+ const bad = checked.filter((h) => h.issues.length);
71
+ const counts = Object.fromEntries(ISSUES.map((k) => [k, checked.filter((h) => h.issues.includes(k)).length]));
72
+ console.log(`${place.key}: ${checked.length} spots in ${((Date.now() - started) / 1000).toFixed(1)} s, ${bad.length} with issues ${JSON.stringify(counts)}`);
73
+
74
+ fs.rmSync(at('explore'), { recursive: true, force: true });
75
+ const painting = fs.readFileSync(at('painting.png'));
76
+ files.write(at('explore', 'heatmap.png'), await heatmap(painting, g, checked));
77
+
78
+ // The juror: the worst spots first, the rest random clean ones, so it also says when all is fine.
79
+ let jury: { tiles: Checked[]; wrong: { id: number; why: string }[] } | undefined;
80
+ if (!noJudge) {
81
+ const severity = (h: Checked) => h.issues.length * 1000 + h.drawn - h.hidden;
82
+ const worst = [...bad].sort((a, b) => severity(b) - severity(a)).slice(0, 10);
83
+ const clean = checked.filter((h) => !h.issues.length);
84
+ const random = Array.from({ length: Math.min(16 - worst.length, clean.length) }, (_, i) => clean[Math.floor(((i + 0.5) * clean.length) / Math.min(16 - worst.length, clean.length))]);
85
+ const tiles = [...worst, ...random];
86
+ const crops = await Promise.all(tiles.map(async (t) => {
87
+ const url = await call<string>(page, CROP_JS, { placeKey: place.key, x: t.x, y: t.y, size: 256 });
88
+ return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64');
89
+ }));
90
+ const sheet = await contactSheet(crops, 256);
91
+ files.write(at('explore', 'sheet.png'), sheet);
92
+ const wrong = await judge(files.memo, `explore:${place.key}`, sheet, tiles.length);
93
+ jury = { tiles, wrong };
94
+ const agree = wrong.filter((w) => tiles[w.id - 1].issues.length).length;
95
+ console.log(` juror: ${wrong.length} of ${tiles.length} tiles look wrong (${agree} also failed a code check)${wrong.map((w) => `\n #${w.id} ${tiles[w.id - 1].issues.join('+') || 'clean'}: ${w.why}`).join('')}`);
96
+ }
97
+
98
+ let fixed: { closed: number; undone: string[] } | undefined;
99
+ if (fix && bad.length) {
100
+ const result = fixGrid(g, checked);
101
+ fixed = { closed: result.closed, undone: result.undone };
102
+ if (result.closed) {
103
+ const flagged = [...(g.flagged ?? []), `explorer closed ${result.closed} cells where the hero looked wrong`];
104
+ files.write(at('geometry.json'), Geometry.parse({ ...result.geometry, flagged }));
105
+ }
106
+ console.log(` fix: closed ${result.closed} cells${result.undone.map((u) => `; not fixed: ${u}`).join('')}`);
107
+ }
108
+
109
+ files.write(at('explore', 'report.json'), {
110
+ spots: checked.length,
111
+ counts,
112
+ bad: bad.map(({ x, y, issues, worst, drawn, hidden }) => ({ x, y, issues, worst, drawn, hidden })),
113
+ ...(jury && { juror: jury.wrong.map((w) => ({ ...w, x: jury.tiles[w.id - 1].x, y: jury.tiles[w.id - 1].y, codeIssues: jury.tiles[w.id - 1].issues })) }),
114
+ ...(fixed && { fixed }),
115
+ });
116
+ }
117
+ } finally {
118
+ await browser.close();
119
+ server.close();
120
+ }
121
+ console.log(`spark calls sent: ${files.memo.sent.spark}`);
122
+ },
123
+ });
@@ -0,0 +1,142 @@
1
+ // diorama export <slug> [dir]: the world as a static folder (index.html, main.js, world-data.json, files/) any web host can serve.
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { defineCommand } from '../registry.ts';
7
+ import { WorldFiles } from '../world/files.ts';
8
+ import { bundle, WEB, worldData } from './play.ts';
9
+
10
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
+
12
+ /** The link preview (X, Facebook, chats): the world's title and pitch, and a 1200x630 picture: the world's own og.jpg when it has one, else its first painted place. */
13
+ async function sharePreview(files: WorldFiles, out: string, url?: string): Promise<string> {
14
+ const world = files.world();
15
+ const title = `${world.title} · Diorama`;
16
+ const pitch = world.pitch.length > 200 ? `${world.pitch.slice(0, 197).replace(/\s+\S*$/, '')}…` : world.pitch;
17
+ const own = files.path('og.jpg');
18
+ const painted = [world.start, ...world.places.map((p) => p.key)].map((key) => files.place(key, 'painting.png')).find((f) => fs.existsSync(f));
19
+ const source = fs.existsSync(own) ? own : painted;
20
+ const base = url ? (url.endsWith('/') ? url : `${url}/`) : '';
21
+ const tags = [`<title>${esc(title)}</title>`, `<meta name="description" content="${esc(pitch)}">`, `<meta property="og:type" content="website">`, `<meta property="og:title" content="${esc(title)}">`, `<meta property="og:description" content="${esc(pitch)}">`];
22
+ if (base) tags.push(`<meta property="og:url" content="${esc(base)}">`);
23
+ if (source) {
24
+ await sharp(source).resize(1200, 630, { fit: 'cover', position: 'attention' }).jpeg({ quality: 85, mozjpeg: true }).toFile(path.join(out, 'og.jpg'));
25
+ // Crawlers want an absolute address; the stamp makes X fetch a new picture after a re-export.
26
+ const image = `${base}og.jpg?v=${Date.now().toString(36)}`;
27
+ tags.push(`<meta property="og:image" content="${esc(image)}">`, '<meta property="og:image:width" content="1200">', '<meta property="og:image:height" content="630">', `<meta property="og:image:alt" content="${esc(world.title)}, a playable paper diorama world">`, '<meta name="twitter:card" content="summary_large_image">', `<meta name="twitter:image" content="${esc(image)}">`);
28
+ } else tags.push('<meta name="twitter:card" content="summary">');
29
+ tags.push(`<meta name="twitter:title" content="${esc(title)}">`, `<meta name="twitter:description" content="${esc(pitch)}">`);
30
+ return tags.join('\n');
31
+ }
32
+
33
+ /** Writes the playable copy of world `slug` to `out`, replacing whatever was there; `home` adds a link back to the page it is shared from; `url` is where the copy will be served, so its link preview has absolute addresses. */
34
+ export async function exportWorld(slug: string, out: string, home?: string, url?: string): Promise<void> {
35
+ const files = new WorldFiles(slug);
36
+ const data = worldData(files);
37
+ fs.rmSync(out, { recursive: true, force: true });
38
+ fs.mkdirSync(out, { recursive: true });
39
+ const copy = async (rel: string, asJpeg = false): Promise<string> => {
40
+ const target = asJpeg ? rel.replace(/\.png$/, '.jpg') : rel;
41
+ const dest = path.join(out, 'files', target);
42
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
43
+ // Paintings are opaque, so JPEG keeps them a fraction of the size; masks and figures stay PNG.
44
+ if (asJpeg) await sharp(files.path(rel)).jpeg({ quality: 88, mozjpeg: true }).toFile(dest);
45
+ else fs.copyFileSync(files.path(rel), dest);
46
+ return target;
47
+ };
48
+ // Cut-outs, sprite sheets and icons only need their look and an exact edge: WebP with lossless alpha is a
49
+ // fraction of the PNG, and those files are most of what a player waits for. A file is changed only when it gets smaller.
50
+ const done = new Map<string, string>();
51
+ const art = async (rel: string): Promise<string> => {
52
+ if (done.has(rel)) return done.get(rel)!;
53
+ let target = rel;
54
+ const webp = /\.png$/i.test(rel) ? await sharp(files.path(rel)).webp({ quality: 90, alphaQuality: 100, effort: 5 }).toBuffer() : undefined;
55
+ if (webp && webp.length < fs.statSync(files.path(rel)).size * 0.9) {
56
+ target = rel.replace(/\.png$/i, '.webp');
57
+ const dest = path.join(out, 'files', target);
58
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
59
+ fs.writeFileSync(dest, webp);
60
+ } else await copy(rel);
61
+ done.set(rel, target);
62
+ return target;
63
+ };
64
+ /** `file` inside `dir`, as art; its new name, still inside `dir`. */
65
+ const artIn = async (dir: string, file: string) => path.posix.relative(dir, await art(`${dir}/${file}`));
66
+ for (const [key, entry] of Object.entries(data.places)) {
67
+ if (entry.painting) entry.painting = await copy(entry.painting, true);
68
+ if (entry.backdrop) entry.backdrop = await copy(entry.backdrop, true);
69
+ if (entry.layers) {
70
+ if (entry.layers.sky) entry.layers.sky = await copy(entry.layers.sky, true);
71
+ if (entry.layers.water) {
72
+ entry.layers.water.tex = await copy(entry.layers.water.tex);
73
+ entry.layers.water.mask = await copy(entry.layers.water.mask);
74
+ }
75
+ if (entry.layers.mist) {
76
+ entry.layers.mist.tex = await copy(entry.layers.mist.tex);
77
+ entry.layers.mist.mask = await copy(entry.layers.mist.mask);
78
+ }
79
+ for (const f of entry.layers.flats) f.file = await art(f.file); // transparent around the flat
80
+ for (const f of [...(entry.layers.smoke ?? []), ...(entry.layers.floats ?? []), ...(entry.layers.falls ?? []), ...(entry.layers.critters ?? []), ...(entry.layers.clouds?.bands ?? [])]) f.file = await art(f.file);
81
+ if (entry.layers.clouds) {
82
+ entry.layers.clouds.clip = await copy(entry.layers.clouds.clip);
83
+ if (entry.layers.clouds.keep) entry.layers.clouds.keep = await copy(entry.layers.clouds.keep);
84
+ }
85
+ }
86
+ for (const thing of entry.geometry?.things ?? []) {
87
+ if (thing.mask) await copy(`places/${key}/${thing.mask}`);
88
+ // A plate is the patch where the thing was, transparent elsewhere.
89
+ if (thing.plate) thing.plate = await artIn(`places/${key}`, thing.plate);
90
+ }
91
+ for (const occ of entry.geometry?.occluders ?? []) if (occ.mask) await copy(`places/${key}/${occ.mask}`);
92
+ if (entry.geometry?.shadows) await copy(`places/${key}/${entry.geometry.shadows}`);
93
+ if (entry.geometry?.depth) await copy(`places/${key}/${entry.geometry.depth}`);
94
+ if (entry.weather?.ripples) entry.weather.ripples = await copy(entry.weather.ripples);
95
+ }
96
+ for (const [i, rel] of Object.entries(data.changes)) data.changes[i] = await art(rel); // transparent around the thing
97
+ for (const [i, rel] of Object.entries(data.changeShapes)) data.changeShapes[i] = await copy(rel);
98
+ for (const figure of Object.values(data.cast)) {
99
+ figure.image = await art(figure.image);
100
+ if (figure.face) await copy(`${figure.face.dir}/${figure.face.info.image}`); // the face atlas is read cell by cell, so as is
101
+ for (const part of figure.rig?.parts ?? []) part.file = await artIn(figure.parts!, part.file);
102
+ if (figure.rig?.sheet) figure.rig.sheet.file = await artIn(figure.parts!, figure.rig.sheet.file);
103
+ if (figure.sprites) for (const f of [...Object.values(figure.sprites.info.facings), ...Object.values(figure.sprites.info.talk ?? {})]) f.sheet = await artIn(figure.sprites.dir, f.sheet);
104
+ // The page draws sprites when it has them; the 3D model behind them (a glb and four big views) is only for ?model=1.
105
+ if (figure.sprites) delete figure.model;
106
+ if (figure.model) for (const file of [figure.model.info.glb, ...Object.values(figure.model.info.views).map((v) => v.image)]) await copy(`${figure.model.dir}/${file}`);
107
+ }
108
+ for (const item of Object.values(data.items ?? {})) item.icon = await art(item.icon); // transparent
109
+ const recordings = Object.values(data.sound?.bySpeaker ?? {}).flatMap(lines => Object.values(lines));
110
+ const audioFiles = new Set([...Object.values(data.sound?.files ?? {}), ...Object.values(data.sound?.lines ?? {}), ...recordings].map(sound => sound.file));
111
+ for (const file of audioFiles) await copy(file);
112
+ fs.writeFileSync(path.join(out, 'world-data.json'), JSON.stringify(data));
113
+ fs.writeFileSync(path.join(out, 'main.js'), await bundle(true));
114
+ let page = fs.readFileSync(path.join(WEB, 'index.html'), 'utf8');
115
+ page = page.replace('<body>', '<body data-shipped>');
116
+ if (home) page = page.replace('<div id="soundless"', `<a id="home" href="${esc(home)}">← Diorama</a>\n<div id="soundless"`);
117
+ page = page.replace('<title>Diorama</title>', await sharePreview(files, out, url));
118
+ fs.writeFileSync(path.join(out, 'index.html'), page);
119
+ }
120
+
121
+ defineCommand({
122
+ name: 'export',
123
+ usage: 'export <slug> [dir] [--zip] [--home <url>] [--url <url>] write a static, playable copy of the world (default exports/<slug>); --zip also packs it for itch.io or e-mail; --home puts a "← Diorama" link to <url> in the corner; --url is where it will be served, for the X/Facebook link preview (title, pitch, og.jpg: worlds/<slug>/og.jpg if you made one, else the first place)',
124
+ async run(args) {
125
+ const zip = args.includes('--zip');
126
+ const flag = (name: string) => { const i = args.indexOf(name); if (i >= 0 && !args[i + 1]) throw new Error(`${name} needs a url`); return i; };
127
+ const h = flag('--home'), u = flag('--url');
128
+ const home = h >= 0 ? args[h + 1] : undefined;
129
+ const url = u >= 0 ? args[u + 1] : undefined;
130
+ const [slug, dir = path.join('exports', slug ?? '')] = args.filter((a, i) => a !== '--zip' && ![h, u].some((f) => f >= 0 && (i === f || i === f + 1)));
131
+ if (!slug) throw new Error('usage: diorama export <slug> [dir] [--zip] [--home <url>] [--url <url>]');
132
+ const out = path.resolve(dir);
133
+ await exportWorld(slug, out, home, url);
134
+ console.log(`export ${slug}: ${out}`);
135
+ if (zip) {
136
+ // index.html at the zip's root, which is what itch.io wants for an HTML5 upload.
137
+ fs.rmSync(`${out}.zip`, { force: true });
138
+ execFileSync('zip', ['-qr', `${out}.zip`, '.'], { cwd: out });
139
+ console.log(`zip: ${out}.zip`);
140
+ }
141
+ },
142
+ });
@@ -0,0 +1,75 @@
1
+ // One Studio job: plan and validate first, then build only the anchor and additions.
2
+ import fs from 'node:fs';
3
+ import { defineCommand, emit, registry } from '../registry.ts';
4
+ import { readDrawn } from '../world/drawings.ts';
5
+ import { designExtension } from '../world/extend.ts';
6
+ import { stagedExtension } from '../world/extend-stage.ts';
7
+ import { WorldFiles, withThingEdits } from '../world/files.ts';
8
+ import { World } from '../world/schema.ts';
9
+ import { Story } from '../story/schema.ts';
10
+ import { checkStory } from '../story/check.ts';
11
+
12
+ export async function extendWorld(slug: string, selected: string[], wish: string) {
13
+ const files = new WorldFiles(slug);
14
+ const oldWorld = World.parse(files.readJson(files.path('world.json')));
15
+ const oldStory = Story.parse(files.readJson(files.path('story.json')));
16
+ const drawings = selected.map(file => {
17
+ const d = readDrawn(files).find(x => `drawings/${x.file}` === file);
18
+ if (!d || !fs.existsSync(files.path(file))) throw new Error(`${file} is not a saved, read drawing`);
19
+ return d;
20
+ });
21
+ // The story uses effective props (including manual edits), while only the base world is persisted.
22
+ const effective = withThingEdits(oldWorld, files.dir);
23
+ const designed = await designExtension(files.memo, effective, oldStory, drawings, wish);
24
+ // Preserve the corrections layer: use each unchanged original place and add only new things to the anchor.
25
+ const oldByKey = new Map(oldWorld.places.map(p => [p.key, p]));
26
+ const effectiveByKey = new Map(effective.places.map(p => [p.key, p]));
27
+ const world = { ...designed.world, places: designed.world.places.map(p => {
28
+ const old = oldByKey.get(p.key);
29
+ if (!old) return p;
30
+ if (!designed.changed.includes(p.key)) return old;
31
+ const before = effectiveByKey.get(p.key)!;
32
+ return { ...old, things: [...old.things, ...p.things.filter(t => !before.things.some(x => x.key === t.key))], exits: p.exits };
33
+ }) };
34
+ const checked = checkStory(withThingEdits(World.parse(world), files.dir), designed.story);
35
+ if (checked.issues.length) throw new Error(`story does not fit the corrected world: ${checked.issues.join('; ')}`);
36
+ const oldNpc = new Set(oldWorld.places.flatMap(p => p.things.filter(t => t.role === 'npc').map(t => t.key)));
37
+ const newNpc = [...new Set(world.places.flatMap(p => p.things.filter(t => t.role === 'npc' && !oldNpc.has(t.key)).map(t => t.key)))];
38
+ const newItems = designed.story.items.filter(i => !oldStory.items.some(o => o.key === i.key)).map(i => i.key);
39
+ files.write(files.path('world.json'), world);
40
+ files.write(files.path('story.json'), designed.story);
41
+ files.write(files.path('routes.json'), checked.routes);
42
+ console.log(`extension planned: ${designed.changed.join(', ')}; ${checked.states} story states checked`);
43
+ const newPlaces = designed.changed.filter(key => !oldWorld.places.some(place => place.key === key));
44
+ emit('extension.progress', { phase: 'places', total: designed.changed.length, newPlaces, ...(newNpc.length && { task: 'characters' }) });
45
+ // Builds run in the private copy; the original remains playable until the whole job succeeds.
46
+ if (newNpc.length) {
47
+ await registry.get('cast')!.run([slug, ...newNpc]);
48
+ const missing = newNpc.filter(key => !fs.existsSync(files.path('cast', `${key}.png`)));
49
+ if (missing.length) throw new Error(`characters could not be painted: ${missing.join(', ')}`);
50
+ }
51
+ for (const [index, place] of designed.changed.entries()) {
52
+ emit('extension.progress', { phase: 'places', place, index: index + 1, total: designed.changed.length });
53
+ console.log(`building ${place}`);
54
+ process.exitCode = 0;
55
+ await registry.get('build')!.run([slug, '--from', 'layout', '--to', 'verify', '--place', place]);
56
+ if (process.exitCode) throw new Error(`${place} is not playable; extension was not installed`);
57
+ }
58
+ emit('extension.progress', { phase: 'audio' });
59
+ if (newItems.length) await registry.get('icons')!.run([slug, ...newItems]);
60
+ await registry.get('voices')!.run([slug]);
61
+ await registry.get('sound')!.run([slug]);
62
+ console.log(`extension complete: ${designed.changed.join(', ')}; new characters: ${newNpc.join(', ') || 'none'}`);
63
+ }
64
+
65
+ defineCommand({
66
+ name: 'extend',
67
+ usage: 'extend <slug> <JSON drawing paths> <wish> extend a built world from saved drawings',
68
+ async run([slug, json, wish]) {
69
+ if (!slug || !json || !wish) throw new Error('usage: diorama extend <slug> <JSON drawing paths> <wish>');
70
+ const selected: unknown = JSON.parse(json);
71
+ if (!Array.isArray(selected) || !selected.length || !selected.every(f => typeof f === 'string' && /^drawings\/[a-z0-9-]+\.png$/.test(f))) throw new Error('drawings must be an array of drawings/*.png paths');
72
+ emit('extension.progress', { phase: 'plan' });
73
+ await stagedExtension(slug, () => extendWorld(slug, selected, wish));
74
+ },
75
+ });
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import { castLanes, mapLimit } from '../world/pool.ts';
3
+ import { CalibrationTarget, setFaceCalibration } from '../scene/face-calibration.ts';
4
+ import { defineCommand, takeOption } from '../registry.ts';
5
+ import { WorldFiles } from '../world/files.ts';
6
+ import { players } from '../world/drawings.ts';
7
+ import { prepareFace, repairFaceFrame, calibrateFaceFrame } from '../scene/faces.ts';
8
+ import { registeredFace } from '../scene/faces-data.ts';
9
+ import { FacePreparationError, LipBox, MouthName } from '../scene/face-work.ts';
10
+ import { prepareLipSync } from '../sound/lipsync-make.ts';
11
+
12
+ export function faceCharacters(files: WorldFiles): string[] {
13
+ const world = files.world();
14
+ return [...new Set([world.hero.key, ...world.places.flatMap(p => p.things.filter(t => t.role === 'npc').map(t => t.key)), ...players(files).map(p => p.key)])];
15
+ }
16
+
17
+ defineCommand({
18
+ name: 'faces',
19
+ usage: 'faces <slug> [key...] prepare speaking faces for paintings, 2D rigs and talk sprites, and cached audio lip-sync [--audio-only] [--image-only] [--force] [--take N] [--rhubarb PATH]',
20
+ async run(args) {
21
+ let take = Number(takeOption(args, 'take', '0'));
22
+ let repairWorkId: string | undefined;
23
+ const executable = takeOption(args, 'rhubarb', '');
24
+ const repairId=takeOption(args,'repair-frame',''), calibrateId=takeOption(args,'calibrate-frame','');
25
+ const revision=takeOption(args,'revision',''), mouth=takeOption(args,'mouth',''), hint=takeOption(args,'hint','');
26
+ const box=takeOption(args,'box','');
27
+ const calibration=takeOption(args,'calibration',''), imageHash=takeOption(args,'image-hash',''), open=takeOption(args,'open','');
28
+ const flag = (name: string) => { const i = args.indexOf(`--${name}`); return i >= 0 ? (args.splice(i, 1), true) : false; };
29
+ const audioOnly = flag('audio-only'), imageOnly = flag('image-only'), force = flag('force');
30
+ const [slug, ...only] = args;
31
+ if (!slug) throw new Error('usage: diorama faces <slug> [key...]');
32
+ const files = new WorldFiles(slug), keys = faceCharacters(files);
33
+ for (const key of only) if (!keys.includes(key)) throw new Error(`${slug} has no character ${key}`);
34
+ if(calibration) {
35
+ if(only.length!==1 || repairId || calibrateId || !['true','false'].includes(open))throw new Error('Choose one inspected calibration');
36
+ await setFaceCalibration(files,only[0],CalibrationTarget.parse(JSON.parse(calibration)),revision,imageHash,LipBox.parse(JSON.parse(box)),open==='true');
37
+ }
38
+ if(repairId || calibrateId) {
39
+ if(only.length!==1 || repairId && calibrateId) throw new Error('Choose one character and one face correction');
40
+ const work = repairId ? await repairFaceFrame(files,only[0],repairId,revision,MouthName.parse(mouth),hint)
41
+ : calibrateFaceFrame(files,only[0],calibrateId,revision,MouthName.parse(mouth),LipBox.parse(JSON.parse(box)));
42
+ take = work.take; repairWorkId = work.id;
43
+ }
44
+ const statusFile = files.path('face-preparation.json');
45
+ const status = fs.existsSync(statusFile) ? files.readJson(statusFile) as Record<string, { face?: string | null; audio?: string[] }> : {};
46
+ for (const key of keys) if (!status[key]) {
47
+ const attempt=files.path('cast',key,'face-attempt.json');
48
+ if(fs.existsSync(attempt)) {
49
+ const previous=files.readJson(attempt) as {state?:string;reason?:string};
50
+ status[key]=previous.state==='failed'&&!registeredFace(files,key)?.info.imagePose?.lips?{face:previous.reason??'Face preparation failed'}:previous.state?{face:null}:{};
51
+ }
52
+ }
53
+ const failures: string[] = [];
54
+ // Each character's face is its own work (its own files under cast/<key>): a few side by side.
55
+ if (!audioOnly) await mapLimit(keys.filter(k => !only.length || only.includes(k)), castLanes(), async (key) => {
56
+ try { await prepareFace(files, key, { take, force, imageOnly, repairWorkId }); (status[key] ??= {}).face = null; }
57
+ catch (e) {
58
+ const reason = e instanceof Error ? e.message : String(e);
59
+ files.write(files.path('cast', key, 'face-attempt.json'), { at: new Date().toISOString(), state: 'failed', take, reason });
60
+ console.log(`${key}: face preparation failed; existing face retained: ${reason}`);
61
+ // A retry (new talk sheets, a new take) that fails leaves the working face in place: the character
62
+ // still speaks with moving lips, so the build is not short of faces. Asked for by name, it is reported.
63
+ if (registeredFace(files, key)?.info.imagePose?.lips && !only.length) { (status[key] ??= {}).face = null; return; }
64
+ failures.push(`${key}: ${reason}`);
65
+ (status[key] ??= {}).face = reason;
66
+ }
67
+ });
68
+ files.write(statusFile, status);
69
+ if (fs.existsSync(files.path('voices.json'))) {
70
+ const audio = await prepareLipSync(files, only, executable || undefined);
71
+ for (const key of [...new Set([...keys, ...Object.keys(status)])].filter(k => !only.length || only.includes(k))) delete (status[key] ??= {}).audio;
72
+ for (const failure of audio.failures ?? []) {
73
+ failures.push(`${failure.speaker}: ${failure.reason}`);
74
+ ((status[failure.speaker] ??= {}).audio ??= []).push(failure.reason);
75
+ }
76
+ files.write(statusFile, status);
77
+ }
78
+ if (failures.length) throw new FacePreparationError(failures);
79
+ },
80
+ });
@@ -0,0 +1,69 @@
1
+ // diorama icons <slug> [item...]: items/<key>.png for every item in story.json, and items/items.json saying how each was made.
2
+ import fs from 'node:fs';
3
+ import sharp from 'sharp';
4
+ import { maskFromPng } from '../image/mask.ts';
5
+ import { maskBox } from '../image/raster.ts';
6
+ import { defineCommand } from '../registry.ts';
7
+ import { cutIcon, itemSources, ItemsInfo, MIN_CUT_PX, paintIcon, readIconFixes, redrawIcon } from '../scene/icons.ts';
8
+ import { Story } from '../story/schema.ts';
9
+ import { WorldFiles } from '../world/files.ts';
10
+
11
+ defineCommand({
12
+ name: 'icons',
13
+ usage: 'icons <slug> [item...] an inventory icon per story item: cut from the painting where the hero picks it up, else painted by Muse (items/fixes.json: how, hint, take)',
14
+ async run([slug, ...only]) {
15
+ if (!slug) throw new Error('usage: diorama icons <slug> [item...]');
16
+ const files = new WorldFiles(slug);
17
+ if (!fs.existsSync(files.path('story.json'))) {
18
+ console.log('no story.json: no items, no icons');
19
+ return;
20
+ }
21
+ const world = files.world();
22
+ const story = Story.parse(files.readJson(files.path('story.json')));
23
+ const sources = itemSources(story);
24
+ const fixes = readIconFixes(files);
25
+ const infoFile = files.path('items', 'items.json');
26
+ const info: ItemsInfo = fs.existsSync(infoFile) ? ItemsInfo.parse(files.readJson(infoFile)) : {};
27
+ // Items the story no longer has lose their icons.
28
+ for (const key of Object.keys(info)) if (!sources[key] && !story.items.some((i) => i.key === key)) delete info[key];
29
+ const items = story.items.filter((i) => only.length === 0 || only.includes(i.key));
30
+ const failed: string[] = [];
31
+ // An item without an icon shows its name in the bag, so one failure does not stop the build.
32
+ await Promise.all(items.map(async (item) => { try {
33
+ const from = sources[item.key];
34
+ const fix = fixes[item.key] ?? {};
35
+ let png: Buffer;
36
+ let how: ItemsInfo[string]['how'];
37
+ const maskFile = from && files.place(from.place, 'masks', `${from.thing}.png`);
38
+ if (from && maskFile && fs.existsSync(maskFile) && fix.how !== 'paint') {
39
+ const painting = fs.readFileSync(files.place(from.place, 'painting.png'));
40
+ const mask = await maskFromPng(fs.readFileSync(maskFile));
41
+ const box = maskBox(mask);
42
+ const side = box ? Math.max(box[2] - box[0], box[3] - box[1]) : 0;
43
+ // A hint from the person means the picture itself should change, so Muse redraws even a large cut-out.
44
+ if (fix.how === 'cut' || (side >= MIN_CUT_PX && !fix.hint)) {
45
+ png = await cutIcon(painting, mask);
46
+ how = 'cut';
47
+ } else {
48
+ // Too small to blow up, or the person asked for changes: Muse redraws the cut-out.
49
+ const crop = await sharp(await cutIcon(painting, mask, 12)).flatten({ background: '#f3ead8' }).png().toBuffer();
50
+ png = await redrawIcon(files.memo, world, item, crop, fix.hint, fix.take);
51
+ how = 'redraw';
52
+ }
53
+ } else {
54
+ if (from) console.log(` ${item.key}: ${from.place}/${from.thing} has no mask, Muse paints it`);
55
+ png = await paintIcon(files.memo, world, item, fix.hint, fix.take);
56
+ how = 'paint';
57
+ }
58
+ files.write(files.path('items', `${item.key}.png`), png);
59
+ info[item.key] = { icon: `items/${item.key}.png`, how, from };
60
+ console.log(`${item.key}: ${how}${from ? ` from ${from.place}/${from.thing}` : ''}`);
61
+ } catch (error) {
62
+ failed.push(item.key);
63
+ console.log(` ${item.key}: no icon (${error instanceof Error ? error.message : error}); the bag shows its name. icon_redo with a hint tries again`);
64
+ } }));
65
+ files.write(infoFile, info);
66
+ if (failed.length) console.log(`no icon for: ${failed.join(', ')}`);
67
+ console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
68
+ },
69
+ });
@@ -0,0 +1,36 @@
1
+ // Command modules; each one calls defineCommand.
2
+ import './doctor.ts';
3
+ import './world.ts';
4
+ import './extend.ts';
5
+ import './story.ts';
6
+ import './playthrough.ts';
7
+ import './layout.ts';
8
+ import './paint.ts';
9
+ import './masks.ts';
10
+ import './plates.ts';
11
+ import './icons.ts';
12
+ import './derive.ts';
13
+ import './backdrop.ts';
14
+ import './weather.ts';
15
+ import './reactions.ts';
16
+ import './sound.ts';
17
+ import './voices.ts';
18
+ import './faces.ts';
19
+ import './voice-retry.ts';
20
+ import './verify.ts';
21
+ import './explore.ts';
22
+ import './build.ts';
23
+ import './cast.ts';
24
+ import './model.ts';
25
+ import './sprites.ts';
26
+ import './style.ts';
27
+ import './play.ts';
28
+ import './export.ts';
29
+ import './publish.ts';
30
+ import './reel.ts';
31
+ import './clip.ts';
32
+ import './setup.ts';
33
+ import './browsers.ts';
34
+ import './action.ts';
35
+ import './studio.ts';
36
+ import './mcp.ts';
@@ -0,0 +1,31 @@
1
+ // diorama layout <slug> [place...]: layout.json, blockin.png (for Muse) and plan.png (for people) for each place.
2
+ import fs from 'node:fs';
3
+ import { renderBlockin, renderPlan } from '../scene/blockin.ts';
4
+ import { readCorrections } from '../scene/corrections.ts';
5
+ import { layOut } from '../scene/layout.ts';
6
+ import { defineCommand } from '../registry.ts';
7
+ import { WorldFiles } from '../world/files.ts';
8
+ import { approvedConcept } from '../world/sketch.ts';
9
+
10
+ defineCommand({
11
+ name: 'layout',
12
+ usage: 'layout <slug> [place...] lay out places (layout.json, blockin.png) with Muse Spark',
13
+ async run([slug, ...only]) {
14
+ if (!slug) throw new Error('usage: diorama layout <slug> [place...]');
15
+ const files = new WorldFiles(slug);
16
+ const world = files.world();
17
+ const places = world.places.filter((p) => only.length === 0 || only.includes(p.key));
18
+ await Promise.all(places.map(async (place) => {
19
+ // A place made from a photo is measured from it, and the photo stands in for the block-in.
20
+ const photoFile = files.place(place.key, 'photo.png');
21
+ const photo = fs.existsSync(photoFile) ? fs.readFileSync(photoFile) : undefined;
22
+ const layout = await layOut(files.memo, world, place, photo, readCorrections(files.place(place.key)).layout?.guidance, approvedConcept(files, 'place', place.key));
23
+ files.write(files.place(place.key, 'layout.json'), layout);
24
+ const blockin = photo ?? await renderBlockin(layout, place);
25
+ files.write(files.place(place.key, 'blockin.png'), blockin);
26
+ files.write(files.place(place.key, 'plan.png'), await renderPlan(layout, place, blockin, world.names));
27
+ console.log(`${place.key}: ${layout.floors.length} floor(s), ${layout.things.length} things, horizon ${layout.horizon}`);
28
+ }));
29
+ console.log(`spark calls sent: ${files.memo.sent.spark}`);
30
+ },
31
+ });