@gtanczyk/diorama 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,100 @@
1
+ // diorama sound <slug>: Spark plans what the world sounds like (sound.json), then every sound is generated,
2
+ // checked and levelled into sound/<id>.mp3 with sound/sounds.json, which the play page mixes live.
3
+ import fs from 'node:fs';
4
+ import { defineCommand, takeOption } from '../registry.ts';
5
+ import { Brief } from '../scene/brief.ts';
6
+ import { Layout } from '../scene/layout.ts';
7
+ import { Kind, makeSounds, seedLibrary, SoundManifest } from '../sound/make.ts';
8
+ import { inputHash } from '../meta/memo.ts';
9
+ import { planIssues, SoundPlan, withoutStrayLoops, withoutSilentLoops, writeSoundPlan, type PlaceSources } from '../sound/plan.ts';
10
+ import { creditGuard, soundQuality } from '../sound/cost.ts';
11
+ import { readSoundFixes } from '../sound/fixes.ts';
12
+ import { elevenKey, musicgenPython } from '../sound/sources.ts';
13
+ import { Story } from '../story/schema.ts';
14
+ import { WorldFiles } from '../world/files.ts';
15
+ import type { World } from '../world/schema.ts';
16
+
17
+ /** What each place's sounds are planned from: its painting brief and floors. */
18
+ export function placeSources(files: WorldFiles, world: World): Record<string, PlaceSources> {
19
+ const sources: Record<string, PlaceSources> = {};
20
+ for (const place of world.places) {
21
+ const read = <T>(file: string, schema: { parse: (v: unknown) => T }) => (fs.existsSync(files.place(place.key, file)) ? schema.parse(files.readJson(files.place(place.key, file))) : undefined);
22
+ sources[place.key] = { brief: read('brief.json', Brief), layout: read('layout.json', Layout) };
23
+ }
24
+ return sources;
25
+ }
26
+
27
+ /** sound.json's `from`: the plan is made again when this changes. */
28
+ export const soundPlanFrom = (world: World, story: Story | null, sources: Record<string, PlaceSources>) => inputHash(JSON.stringify(world), JSON.stringify(story), JSON.stringify(sources));
29
+
30
+ defineCommand({
31
+ name: 'sound',
32
+ usage: 'sound <slug> plan and generate ambience, loops, footsteps, one-shots and music [--replan] [--plan-only] [--only bed,loop,step,shot,music] [--redo <id,id>: make only these sounds again, with their hints from sound/fixes.json (sound_redo)] [--music auto|musicgen|eleven] [--draft: shorter loops and cheaper speech; final quality is the default]',
33
+ async run(args) {
34
+ // Keep this generation at one quality even if Setup changes while it runs.
35
+ process.env.DIORAMA_SOUND = soundQuality(args);
36
+ const replan = args.includes('--replan');
37
+ const planOnly = args.includes('--plan-only');
38
+ const only = takeOption(args, 'only', '');
39
+ const redo = takeOption(args, 'redo', '');
40
+ // auto: MusicGen when DIORAMA_PYTHON is set up for it, else ElevenLabs.
41
+ const musicFrom = takeOption(args, 'music', 'auto');
42
+ if (!['auto', 'musicgen', 'eleven'].includes(musicFrom)) throw new Error('--music takes auto, musicgen or eleven');
43
+ const [slug] = args.filter((a) => !a.startsWith('--'));
44
+ if (!slug) throw new Error('usage: diorama sound <slug>');
45
+ const files = new WorldFiles(slug);
46
+ const world = files.world();
47
+ const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
48
+ const sources = placeSources(files, world);
49
+
50
+ // sound.json is the plan people may edit. It is made again with --replan, or when what it was made from
51
+ // (world, story, briefs, floors) has changed since, so its shots and loops keep matching the story's things.
52
+ const planFile = files.path('sound.json');
53
+ const from = soundPlanFrom(world, story, sources);
54
+ const old = fs.existsSync(planFile) ? SoundPlan.parse(files.readJson(planFile)) : undefined;
55
+ let plan: SoundPlan;
56
+ // A plan made before silent things lost their loops drops them here too, before anything is paid for.
57
+ if (old && !replan && old.from === from) plan = { ...old, ...withoutSilentLoops(old, sources) };
58
+ else {
59
+ if (old && !replan) console.log('the world, story or paintings changed since sound.json was planned: planning again (hand edits to it are replaced)');
60
+ plan = { from, ...withoutStrayLoops(await writeSoundPlan(files.memo, world, story, sources), world) };
61
+ files.write(planFile, plan);
62
+ }
63
+ const issues = planIssues(plan, world, sources);
64
+ const loops = Object.values(plan.places).reduce((n, p) => n + p.loops.length, 0);
65
+ console.log(`plan: music, ${Object.keys(plan.places).length} beds, ${loops} loops, ${Object.keys(plan.steps).length} surfaces, ${plan.shots.length} one-shots · ${planFile}`);
66
+ for (const issue of issues) console.log(` flag ${issue}`);
67
+ if (planOnly) return;
68
+ if (!elevenKey()) {
69
+ console.log('no ELEVENLABS_API_KEY: the plan is written, no sounds generated; the world plays silent');
70
+ return;
71
+ }
72
+
73
+ const guard = await creditGuard(elevenKey(), 'sound');
74
+ const python = musicFrom === 'eleven' ? undefined : musicgenPython();
75
+ if (musicFrom === 'musicgen' && !python) throw new Error('--music musicgen needs DIORAMA_PYTHON pointing at a Python with torch, transformers and scipy');
76
+ console.log(`music: ${python ? 'MusicGen (Meta open weights) on this machine' : 'ElevenLabs (set DIORAMA_PYTHON to a Python with torch + transformers for MusicGen)'}`);
77
+ const manifestFile = files.path('sound', 'sounds.json');
78
+ const made0: SoundManifest = fs.existsSync(manifestFile) ? SoundManifest.parse(files.readJson(manifestFile)) : { sounds: {}, failures: {} };
79
+ const kinds = only ? only.split(',').map((k) => Kind.parse(k.trim())) : undefined;
80
+ const ids = redo ? redo.split(',').map((id) => id.trim()).filter(Boolean) : undefined;
81
+ const fixes = readSoundFixes(files);
82
+ const made = await makeSounds(files.memo, plan, (id, mp3) => {
83
+ files.write(files.path('sound', `${id}.mp3`), mp3);
84
+ return `sound/${id}.mp3`;
85
+ }, { python, only: kinds, ids, fixes, log: (line) => console.log(line), old: made0.sounds, failedBefore: made0.failures, have: (e) => fs.existsSync(files.path(e.file)) });
86
+ const partial = !!(kinds || ids);
87
+ // A partial run keeps what it did not touch.
88
+ const sounds = { ...(partial ? made0.sounds : {}), ...made.sounds };
89
+ // Failed replacements cannot silently retain the rejected prior asset in a partial rebuild.
90
+ for (const id of Object.keys(made.failures)) delete sounds[id];
91
+ const failures = { ...(partial ? made0.failures : {}), ...made.failures };
92
+ for (const id of Object.keys(made.sounds)) delete failures[id];
93
+ files.write(manifestFile, { sounds, failures });
94
+ for (const [id, e] of Object.entries(sounds)) if (fs.existsSync(files.path(e.file))) seedLibrary(id, e, fs.readFileSync(files.path(e.file)));
95
+ const s = files.memo.sent;
96
+ console.log(`sound ${slug}: ${Object.keys(sounds).length} sounds · sent: spark ${s.spark}, sound ${s.sound}, music ${s.music} · ${await guard.report()} · ${manifestFile}`);
97
+ // A world plays without a sound: one that failed stays silent and the build goes on. Studio shows it with a retry.
98
+ if (Object.keys(failures).length) console.log(`warning: ${Object.keys(failures).length} sounds stay silent (failed quality or generation): ${Object.keys(failures).join(', ')}; see sound/sounds.json failures`);
99
+ },
100
+ });
@@ -0,0 +1,232 @@
1
+ // diorama sprites <slug> [key]: walk and talk sprite sheets drawn by Muse over the rigged model's poses,
2
+ // only those the world and its story use (src/model/plan.ts).
3
+ import fs from 'node:fs';
4
+ import { bindExistingAnimation, bindAnimation } from '../scene/animation-source.ts';
5
+ import path from 'node:path';
6
+ import { gestureIndex } from '../play/gestures.ts';
7
+ import { defineCommand, takeOption } from '../registry.ts';
8
+ import { planSprites, type CharacterPlan } from '../model/plan.ts';
9
+ import { cutSheet, FACINGS, flatGuide, onGrey, paintSheet, POSE_OK, renderGuides, modelSheet, SHEET, TRACED, uvFaceLost, type Facing } from '../model/sprites.ts';
10
+ import { animationSettings, animationOptions, saveAnimationSettings, type AnimationSettings } from '../model/animation.ts';
11
+ import { Story } from '../story/schema.ts';
12
+ import { fixedLooks, readCastFixes } from '../scene/cast-fixes.ts';
13
+ import { refitIfOld } from '../model/fit.ts';
14
+ import { modelProblem } from '../model/glb.ts';
15
+ import { castStyle, readStyled } from '../world/styled.ts';
16
+ import { WorldFiles } from '../world/files.ts';
17
+
18
+ const LEGS_OK = 0.6;
19
+ // Walk takes painted again at most, while the best one is traced or left the pose.
20
+ const RETAKES = 4;
21
+ const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
22
+
23
+ defineCommand({
24
+ name: 'sprites',
25
+ usage: 'sprites <slug> [key] walk and talk sheets for what the story uses: automatic UV/16 for new textured models, painted/8 fallback (cast/<key>/sprites/; needs `diorama model` first) [--texture model|views] [--coat-correction on|off] [--frames 8|16|32: model texture only; choices persist per character] [--plan: only print what would be drawn] [--take N] [--tries N: up to N takes per walk facing while a frame has its legs wrong]',
26
+ async run(args) {
27
+ const options = animationOptions(takeOption(args, 'texture', ''), takeOption(args, 'frames', ''), takeOption(args, 'coat-correction', ''));
28
+ const takeArg = takeOption(args, 'take', '');
29
+ const tries = Number(takeOption(args, 'tries', '1'));
30
+ const planOnly = args.includes('--plan');
31
+ const [slug, keyArg] = args.filter((a) => !a.startsWith('--'));
32
+ if (!slug) throw new Error('usage: diorama sprites <slug> [key]');
33
+ const files = new WorldFiles(slug);
34
+ const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : undefined;
35
+ const plans = (await planSprites(files, story)).filter((p) => !keyArg || p.key === keyArg);
36
+ if (!plans.length) throw new Error(keyArg ? `${keyArg} needs no sprites (not the hero, says no lines)` : 'nobody needs sprites');
37
+ const settings = new Map(plans.map((p) => [p.key, animationSettings(files, p.key, options)]));
38
+ if (!planOnly) files.write(files.path('cast', 'sprites-plan.json'), plans);
39
+ // Whether a usable 3D model is there: a creature without one (flat reconstructions) moves as its cut-out.
40
+ const modelled = (key: string) => fs.existsSync(files.path('cast', key, 'model', 'model.json')) && !modelProblem(files.path('cast', key, 'model'));
41
+ for (const p of plans) {
42
+ const talk = Object.entries(p.talk).map(([f, g]) => `${f} [${g.join(', ')}]`).join('; ');
43
+ console.log(p.creature ? `${p.key}: a creature · 0 Muse sheets (${modelled(p.key) ? 'its 3D model moves' : 'no usable 3D model: the cut-out moves as one piece'})` : `${p.key}: ${settings.get(p.key)!.texture === 'model' ? `UV ${settings.get(p.key)!.frames} frames` : 'painted 8 frames'} · walk ${p.walk.join(', ') || '-'} · talk ${talk || '-'} · ${settings.get(p.key)!.texture === 'model' ? 0 : p.walk.length + Object.keys(p.talk).length} Muse sheets`);
44
+ }
45
+ if (planOnly) return;
46
+ for (const p of plans) {
47
+ // A creature has no walk or gestures to paint: its 3D model hops, squashes and blinks in the game itself.
48
+ if (p.creature) {
49
+ console.log(modelled(p.key) ? `${p.key}: a creature, no sheets: it moves as its 3D model` : `${p.key}: a creature, no sheets and no usable 3D model: the cut-out moves as one piece (cast_animate, or diorama model ${slug} ${p.key}, tries a model)`);
50
+ continue;
51
+ }
52
+ if (!fs.existsSync(files.path('cast', p.key, 'model', 'model.json'))) {
53
+ console.log(`${p.key}: no model yet, skipped (diorama model ${slug} ${p.key})`);
54
+ continue;
55
+ }
56
+ const bad = modelProblem(files.path('cast', p.key, 'model'));
57
+ if (bad) {
58
+ console.log(`${p.key}: model is ${bad}, skipped: walks with the 2D rig (diorama model ${slug} ${p.key} tries again)`);
59
+ continue;
60
+ }
61
+ bindExistingAnimation(files, p.key);
62
+ const selected = settings.get(p.key)!;
63
+ if (selected.texture === 'model') await drawModelCharacter(files, p, selected);
64
+ else {
65
+ // The person's sprites_redo rounds (cast/fixes.json) start past the takes painted before, also in a rebuild.
66
+ const take = takeArg ? Number(takeArg) : (RETAKES + 1) * (readCastFixes(files)[p.key]?.spriteTake ?? 0);
67
+ await drawCharacter(files, p, take, tries, selected);
68
+ }
69
+ if (selected.texture === 'model' || Object.keys(options).length || fs.existsSync(files.path('cast', p.key, 'animation.json'))) saveAnimationSettings(files, p.key, selected);
70
+ const modelFile = files.path('cast', p.key, 'model', 'model.json');
71
+ const model = files.readJson(modelFile) as Record<string, unknown>;
72
+ if (selected.texture === 'model') model.texture = 'model';
73
+ else delete model.texture;
74
+ if (selected.coatCorrection !== undefined) model.coatCorrection = selected.coatCorrection;
75
+ files.write(modelFile, model);
76
+ bindAnimation(files, p.key, 'sprites');
77
+ }
78
+ console.log(`calls sent: muse ${files.memo.sent.muse}, spark ${files.memo.sent.spark}`);
79
+ },
80
+ });
81
+
82
+ async function drawCharacter(files: WorldFiles, plan: CharacterPlan, take: number, tries: number, settings: AnimationSettings) {
83
+ const { key } = plan;
84
+ const world = files.world();
85
+ const who = [world.hero, ...world.places.flatMap((p) => p.things)].find((w) => w.key === key);
86
+ const looks = who ? fixedLooks(files, who).looks : '';
87
+ // A world given another style names it, so the sheets follow the restyled character, not the gouache they were made for.
88
+ const style = readStyled(files).from ? castStyle(files, world, key) : undefined;
89
+ const modelDir = files.path('cast', key, 'model');
90
+ const projected: AnimationSettings = { ...settings, texture: 'views', frames: 8 };
91
+ // An older model's views were fitted on the whole silhouette only: the guides would show the profile's face twice.
92
+ if (await refitIfOld(modelDir)) console.log(`${key}: views fitted again, slice by slice`);
93
+ const view = (name: string) => fs.readFileSync(`${modelDir}/${name}.png`);
94
+ const out = (name: string) => files.path('cast', key, 'sprites', name);
95
+
96
+ // Walking: the first facing (the profile when it is used) is painted first; the others also see it,
97
+ // so patches and colours stay the same. Muse often hands the textured guide back with a filter over it
98
+ // (mean `same` above TRACED): that sheet looks like the 3D render, and as the first facing it passes the
99
+ // look on to every other facing. So a take that is traced or left the pose is painted again, up to
100
+ // RETAKES times, and the first drawn take that keeps the pose wins. Retakes cycle through three guides,
101
+ // because no one of them works for every facing (foghorn, 3 takes each): over a grey clay figure Muse
102
+ // draws the back view every time but turns the three-quarter back into a profile; over flat colour
103
+ // blocks it keeps the pose about half the time; over the textured render it traces about 3 takes in 4.
104
+ // A frame whose legs do not follow the guide makes the walk skip: with --tries, more takes, and the
105
+ // whole take whose legs follow best wins (frames mixed from several takes do not match).
106
+ const facings: Record<string, { sheet: string; yaw: number; stand: number; off: number[]; iou: number[]; legs: number[]; same: number[] }> = {};
107
+ if (plan.walk.length) {
108
+ const guides = await renderGuides(modelDir, plan.walk, undefined, false, projected);
109
+ const clay = await renderGuides(modelDir, plan.walk, undefined, true, projected);
110
+ const shown: Record<string, Buffer[]> = {};
111
+ for (const f of plan.walk) {
112
+ const grey = await onGrey(guides[f]);
113
+ files.write(out(`guide-${f}.png`), grey);
114
+ shown[f] = [grey, await onGrey(clay[f]), await flatGuide(grey)];
115
+ }
116
+ const first: Facing = plan.walk.includes('right') ? 'right' : plan.walk[0];
117
+ const refsFor = (f: Facing, painted?: Buffer) => [f.startsWith('back') ? view('back') : view('front'), painted ?? view('right')];
118
+ const painted: Record<string, Buffer> = {};
119
+ const walk = async (f: Facing, refs: Buffer[]) => {
120
+ type Take = Awaited<ReturnType<typeof cutSheet>> & { painted: Buffer; n: number };
121
+ const takes: Take[] = [];
122
+ let n = take;
123
+ const another = async () => {
124
+ const p = await paintSheet(files.memo, key, f, looks, shown[f][(n - take) % shown[f].length], refs, n, 0, style);
125
+ // Always measured against the textured guide: that is the look a traced sheet keeps.
126
+ takes.push({ ...(await cutSheet(guides[f], p)), painted: p, n: n++ });
127
+ };
128
+ const traced = (t: Take) => mean(t.same) > TRACED;
129
+ // A take that left the pose (Muse turned some frames to another facing) is worst, then traced takes;
130
+ // among drawn takes the legs that follow best win, among traced ones the least traced.
131
+ const rank = (t: Take) => (mean(t.iou) < POSE_OK ? 0 : traced(t) ? 1 : 2);
132
+ const pick = () => takes.reduce((a, b) => {
133
+ if (rank(a) !== rank(b)) return rank(b) > rank(a) ? b : a;
134
+ return traced(a) ? (mean(b.same) < mean(a.same) ? b : a) : (mean(b.legs) > mean(a.legs) ? b : a);
135
+ });
136
+ await another();
137
+ for (let t = 1; t <= RETAKES && rank(pick()) < 2; t++) await another();
138
+ for (let t = 1; t < tries && Math.min(...pick().legs) < LEGS_OK; t++) await another();
139
+ const best = pick();
140
+ painted[f] = best.painted;
141
+ files.write(out(`painted-${f}.png`), best.painted);
142
+ files.write(out(`${f}.png`), best.sheet);
143
+ facings[f] = { sheet: `${f}.png`, yaw: FACINGS[f], stand: best.stand, off: best.off, iou: best.iou, legs: best.legs, same: best.same };
144
+ console.log(`${key} walk ${f}: take ${best.n + 1} (${['textured', 'clay', 'flat'][(best.n - take) % 3]} guide, ${takes.length} painted), pose IoU ${mean(best.iou).toFixed(3)}, legs ${mean(best.legs).toFixed(3)} (worst ${Math.min(...best.legs)}), same ${mean(best.same).toFixed(3)}${best.off.length ? `, off the walk: cell ${best.off.join(', ')}` : ''}${traced(best) ? ' <- Muse kept the 3D look, try --take' : mean(best.iou) < POSE_OK ? ' <- frames left the pose, try --take' : ''}`);
145
+ };
146
+ await walk(first, [view('front'), view('right')]);
147
+ await Promise.all(plan.walk.filter((f) => f !== first).map((f) => walk(f, refsFor(f, painted[first]))));
148
+ }
149
+
150
+ // Talking: the gestures this character's lines use, in the facing its conversation is held in. The
151
+ // painted views are the reference, not a walk sheet: with a walk sheet Muse draws the legs walking.
152
+ const talk: Record<string, { sheet: string; yaw: number; gestures: string[]; iou: number[]; same: number[] }> = {};
153
+ await Promise.all(Object.entries(plan.talk).map(async ([facing, names]) => {
154
+ const f = facing as Facing;
155
+ const used = names.map(gestureIndex);
156
+ // Muse redraws a half-empty grid as a few big figures, so the used gestures repeat to fill all 8
157
+ // cells (the sheet costs the same); cell i < used.length is gestures[i].
158
+ const cells = Array.from({ length: SHEET.cols * SHEET.rows }, (_, i) => used[i % used.length]);
159
+ const guide = (await renderGuides(modelDir, [f], cells, false, projected))[f];
160
+ const g = await onGrey(guide);
161
+ files.write(out(`talk-guide-${f}.png`), g);
162
+ const refs = [view('front'), view('right')];
163
+ let p = await paintSheet(files.memo, key, f, looks, g, refs, take, used.length, style);
164
+ let cut = await cutSheet(guide, p);
165
+ const score = () => mean(cut.same.slice(0, used.length));
166
+ // From some angles the hands hide in front of the body and Muse hands the guide back unchanged.
167
+ for (let t = 1; t <= 2 && score() > TRACED; t++) {
168
+ p = await paintSheet(files.memo, key, f, looks, g, refs, take + t, used.length, style);
169
+ cut = await cutSheet(guide, p);
170
+ }
171
+ files.write(out(`talk-painted-${f}.png`), p);
172
+ files.write(out(`talk-${f}.png`), cut.sheet);
173
+ talk[f] = { sheet: `talk-${f}.png`, yaw: FACINGS[f], gestures: names, iou: cut.iou.slice(0, used.length), same: cut.same.slice(0, used.length) };
174
+ console.log(`${key} talk ${f} [${names.join(', ')}]: pose IoU ${mean(cut.iou.slice(0, used.length)).toFixed(3)}, same ${score().toFixed(3)}${score() > TRACED ? ' <- Muse kept the 3D look, try --take' : ''}`);
175
+ }));
176
+
177
+ files.write(out('sprites.json'), {
178
+ cols: SHEET.cols, rows: SHEET.rows, frames: SHEET.cols * SHEET.rows, width: SHEET.width, height: SHEET.height,
179
+ // In each cell the feet stand at (cell width / 2, cell height * feetAt); the figure is about `px` tall.
180
+ feetAt: SHEET.feetAt, px: SHEET.px,
181
+ // Walk sheets: 8 frames of a full cycle; `stand` is the frame shown standing still; a walk skips the `off` cells.
182
+ facings,
183
+ // Talk sheets: cell i is gestures[i]; cell 0 is `listen` (also how an NPC stands when idle).
184
+ talk,
185
+ // The gesture for each line this character says, by its text.
186
+ lines: plan.lines,
187
+ });
188
+ }
189
+
190
+ /** Direct UV sheets keep anatomy tied to the mesh; no Muse repainting or silhouette fitting. */
191
+ export async function drawModelCharacter(files: WorldFiles, plan: CharacterPlan, settings: AnimationSettings) {
192
+ const modelDir = files.path('cast', plan.key, 'model');
193
+ const grid = modelSheet(settings.frames);
194
+ // TRELLIS often bakes a blank face: then the front painting goes on it (Spark compares the two heads once).
195
+ const modelFile = path.join(modelDir, 'model.json');
196
+ if (fs.existsSync(modelFile) && fs.existsSync(path.join(modelDir, 'front.png'))) {
197
+ const fromViews = await uvFaceLost(files.memo, plan.key, modelDir, settings).catch((error) => {
198
+ console.log(`${plan.key}: face check failed (${error instanceof Error ? error.message : String(error)}); the model texture's face stays`);
199
+ return false;
200
+ });
201
+ const info = files.readJson(modelFile) as Record<string, unknown>;
202
+ if (fromViews) info.face = 'views';
203
+ else delete info.face;
204
+ files.write(modelFile, info);
205
+ console.log(`${plan.key}: face ${fromViews ? 'from the front painting (the UV face came out blank)' : 'from the model texture'}`);
206
+ }
207
+ const facings: Record<string, { sheet: string; yaw: number; stand: number; off: number[] }> = {};
208
+ const talk: Record<string, { sheet: string; yaw: number; gestures: string[] }> = {};
209
+ const images: Record<string, Buffer> = {};
210
+ // Finish rendering everything before replacing any active sheet or its metadata.
211
+ if (plan.walk.length) {
212
+ const walk = await renderGuides(modelDir, plan.walk, undefined, false, settings);
213
+ for (const f of plan.walk) {
214
+ const sheet = `model-${settings.frames}-${f}.png`;
215
+ images[sheet] = walk[f];
216
+ facings[f] = { sheet, yaw: FACINGS[f], stand: settings.frames, off: [] };
217
+ }
218
+ }
219
+ for (const [facing, names] of Object.entries(plan.talk)) {
220
+ const f = facing as Facing;
221
+ const gestures = names.map(gestureIndex);
222
+ if (gestures.some((i) => i < 0)) throw new Error(`${plan.key}: unknown talking gesture`);
223
+ const sheet = `model-${settings.frames}-talk-${f}.png`;
224
+ images[sheet] = (await renderGuides(modelDir, [f], gestures, false, settings))[f];
225
+ talk[f] = { sheet, yaw: FACINGS[f], gestures: names };
226
+ }
227
+ const out = (name: string) => files.path('cast', plan.key, 'sprites', name);
228
+ for (const [name, image] of Object.entries(images)) files.write(out(name), image);
229
+ const { elevation: _elevation, ...layout } = grid;
230
+ files.write(out('sprites.json'), { ...layout, facings, talk, lines: plan.lines });
231
+ console.log(`${plan.key}: UV sprites · ${settings.frames} walk frames + standing · ${Object.keys(talk).length} talk sheets`);
232
+ }
@@ -0,0 +1,37 @@
1
+ // diorama story <slug>: puzzles, items, dialogue and endings for world.json, checked by playing every state.
2
+ // The person's wish and reworded lines (story-fixes.json) are applied every time.
3
+ // diorama story <slug> --check: re-check story.json.
4
+ import { defineCommand } from '../registry.ts';
5
+ import { checkStory } from '../story/check.ts';
6
+ import { designStory } from '../story/design.ts';
7
+ import { applyTexts, readStoryFixes } from '../story/fixes.ts';
8
+ import { WorldFiles } from '../world/files.ts';
9
+
10
+ defineCommand({
11
+ name: 'story',
12
+ usage: 'story <slug> write story.json with Muse Spark and check every ending is reachable [--check]',
13
+ async run(args) {
14
+ const checkOnly = args.includes('--check');
15
+ const [slug] = args.filter((a) => !a.startsWith('--'));
16
+ if (!slug) throw new Error('usage: diorama story <slug> [--check]');
17
+ const files = new WorldFiles(slug);
18
+ const world = files.world();
19
+ const fixes = readStoryFixes(files);
20
+ let report;
21
+ if (checkOnly) report = checkStory(world, files.readJson(files.path('story.json')));
22
+ else {
23
+ if (fixes.wish) console.log(` your wish: ${fixes.wish}`);
24
+ const designed = await designStory(files.memo, world, fixes.wish);
25
+ const { story, skipped } = applyTexts(designed.story, fixes);
26
+ for (const path of skipped) console.log(` your wording for ${path} no longer fits the story; left out`);
27
+ if (Object.keys(fixes.texts).length > skipped.length) console.log(` ${Object.keys(fixes.texts).length - skipped.length} line(s) in your wording`);
28
+ files.write(files.path('story.json'), story);
29
+ report = checkStory(world, story);
30
+ }
31
+ if (report.issues.length) throw new Error(`story.json has ${report.issues.length} issue(s):\n- ${report.issues.join('\n- ')}`);
32
+ files.write(files.path('routes.json'), report.routes);
33
+ console.log(`${report.states} states played, every ending reachable, no dead ends`);
34
+ for (const r of report.routes) console.log(` ${r.ending} in ${r.steps.length} steps: ${r.steps.join(' → ')}`);
35
+ if (!checkOnly) console.log(`spark calls sent: ${files.memo.sent.spark}`);
36
+ },
37
+ });
@@ -0,0 +1,65 @@
1
+ // diorama studio [slug]: the GUI. A local page to start, watch and fix worlds, driven by the same actions
2
+ // as `diorama action` and the Muse Code agent.
3
+ import { spawn } from 'node:child_process';
4
+ import { actions } from '../actions/registry.ts';
5
+ import { defineCommand, takeOption } from '../registry.ts';
6
+ import { writeStudioFile } from '../studio/discovery.ts';
7
+ import type { StudioEvent } from '../studio/events.ts';
8
+ import { startStudio } from '../studio/server.ts';
9
+
10
+ defineCommand({
11
+ name: 'studio',
12
+ usage: 'studio [slug] open Diorama Studio, the GUI, on 127.0.0.1 [--port N] [--no-open] [--quiet] [--verbose: every line jobs print]',
13
+ async run(args) {
14
+ const port = Number(takeOption(args, 'port', '0'));
15
+ const noOpen = args.includes('--no-open');
16
+ const quiet = args.includes('--quiet');
17
+ const verbose = args.includes('--verbose');
18
+ const slug = args.find((a) => !a.startsWith('--'));
19
+ const studio = await startStudio({ port });
20
+ const forget = writeStudioFile(process.cwd(), { url: studio.url, agentToken: studio.agentToken });
21
+ // The token rides in the fragment: browsers never send it to a server or put it in a Referer.
22
+ const link = `${studio.url}${slug ? `w/${slug}` : ''}#${studio.token}`;
23
+ console.log(`Diorama Studio: ${link}\n(only this link opens it; Ctrl+C to stop)`);
24
+ if (!quiet) studio.events.subscribe((e) => {
25
+ const line = describe(e, verbose);
26
+ if (line) console.log(`${new Date(e.at).toTimeString().slice(0, 8)} ${e.world ? `${e.world}: ` : ''}${line}`);
27
+ });
28
+ if (!noOpen) {
29
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
30
+ spawn(opener, [link], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
31
+ }
32
+ const stop = async () => {
33
+ forget();
34
+ await studio.close();
35
+ process.exit(0);
36
+ };
37
+ process.on('SIGINT', stop);
38
+ process.on('SIGTERM', stop);
39
+ await new Promise(() => {});
40
+ },
41
+ });
42
+
43
+ /** One terminal line per event worth seeing: what runs, what ends, what the agent does. Keys are already redacted. */
44
+ function describe(e: StudioEvent, verbose: boolean): string | null {
45
+ const job = e.job as { label?: string; by?: string; state?: string; error?: string } | undefined;
46
+ switch (e.type) {
47
+ case 'job.start': return `started ${job?.label} (${job?.by})`;
48
+ case 'job.end': return `${job?.state} ${job?.label}${job?.error ? `: ${job.error}` : ''}`;
49
+ case 'step.start': return `> ${e.step}`;
50
+ case 'step.end': return `ok ${e.step} ${e.seconds}s`;
51
+ case 'step.fail': return `FAILED ${e.step}: ${e.error}`;
52
+ case 'place.verdict': return `${e.place}: ${e.ok ? 'playable' : 'NOT playable'}`;
53
+ case 'action.start': return e.by === 'agent' || !actions.get(String(e.action))?.reads ? `${e.by}: ${e.action}` : null;
54
+ case 'action.end': return e.ok ? null : `${e.by}: ${e.action} failed: ${e.error}`;
55
+ case 'history.undo': return `${e.by}: undo to ${e.id}`;
56
+ case 'lock.changed': return `${e.by}: ${e.locked ? 'locked' : 'unlocked'} ${e.place ?? 'world'}`;
57
+ case 'agent.user': return `you -> agent: ${String(e.text).slice(0, 120)}`;
58
+ case 'agent.message': return `agent: ${String(e.text).slice(0, 200)}`;
59
+ case 'agent.turn.end': return e.status === 'completed' ? null : `agent stopped: ${e.error ?? e.status}`;
60
+ case 'agent.error': return `agent error: ${e.text}`;
61
+ case 'agent.agent': return `agent is now ${e.label}`;
62
+ case 'log': return verbose || e.stream === 'err' ? ` ${e.line}` : null;
63
+ default: return null;
64
+ }
65
+ }
@@ -0,0 +1,87 @@
1
+ // diorama style <slug> "<style>": give a world (or one place, or the cast) another painting style, and repaint
2
+ // what is made in it with the composition kept, so masks, walk grids and the person's fixes still fit.
3
+ import fs from 'node:fs';
4
+ import { defineCommand, registry, takeOption } from '../registry.ts';
5
+ import { readCastFixes, castFixesFile } from '../scene/cast-fixes.ts';
6
+ import { readCorrections, writeCorrections } from '../scene/corrections.ts';
7
+ import { WorldFiles } from '../world/files.ts';
8
+ import { castStyle, placeStyle, readStyled, writeStyled } from '../world/styled.ts';
9
+ import { castKeys, planLine, stylePlan, type Scope } from '../world/style-plan.ts';
10
+
11
+ const run = (name: string, args: string[]) => registry.get(name)!.run(args);
12
+
13
+ defineCommand({
14
+ name: 'style',
15
+ usage: 'style <slug> "<style>" paint the world in another style: each place repainted with its composition kept (then masks to verify again), the cast repainted, their models and sprites made again [--place KEY: only that place] [--cast KEY|all: only the cast] [--estimate: only say what it redoes and costs]',
16
+ async run(args) {
17
+ const placeKey = takeOption(args, 'place', '');
18
+ const castKey = takeOption(args, 'cast', '');
19
+ const estimate = args.includes('--estimate');
20
+ const [slug, style] = args.filter((a) => a !== '--estimate');
21
+ if (!slug || !style) throw new Error('usage: diorama style <slug> "<style>" [--place KEY] [--cast KEY|all] [--estimate]');
22
+ if (style.trim().length < 10) throw new Error('say the style in a few words: medium, palette, light (at least 10 letters)');
23
+ const files = new WorldFiles(slug);
24
+ const world = files.world();
25
+ const scope: Scope = placeKey ? 'place' : castKey ? 'cast' : 'world';
26
+ const key = placeKey || (castKey && castKey !== 'all' ? castKey : undefined);
27
+ if (placeKey && !world.places.some((p) => p.key === placeKey)) throw new Error(`${slug} has no place ${placeKey}: ${world.places.map((p) => p.key).join(', ')}`);
28
+ if (scope === 'cast' && key && !castKeys(world, files).includes(key)) throw new Error(`${slug} has no character ${key}: ${castKeys(world, files).join(', ')}`);
29
+ const plan = stylePlan(files, world, scope, key);
30
+ console.log(`style ${scope}${key ? ` ${key}` : ''}: ${planLine(plan)}`);
31
+ if (estimate) return;
32
+
33
+ // What is painted already keeps the style it was painted in, so paint and cast see the change and repaint it.
34
+ const styled = readStyled(files);
35
+ for (const p of world.places) if (!styled.places[p.key] && fs.existsSync(files.place(p.key, 'painting.png'))) styled.places[p.key] = placeStyle(files, world, p.key);
36
+ for (const k of castKeys(world, files)) if (!styled.cast[k] && fs.existsSync(files.path('cast', `${k}-painting.png`))) styled.cast[k] = castStyle(files, world, k);
37
+ styled.from ??= world.style;
38
+ writeStyled(files, styled);
39
+
40
+ // The new style: the world's (a place's or a character's own style gives way to it), or one place's or character's own.
41
+ const fixes = readCastFixes(files);
42
+ const castFix = (k: string, s: string | undefined) => {
43
+ if (s) fixes[k] = { ...fixes[k], style: s };
44
+ else if (fixes[k]) delete fixes[k].style;
45
+ };
46
+ if (scope === 'world') {
47
+ files.write(files.path('world.json'), { ...JSON.parse(fs.readFileSync(files.path('world.json'), 'utf8')), style });
48
+ for (const p of world.places) {
49
+ const c = readCorrections(files.place(p.key));
50
+ if (!c.style) continue;
51
+ delete c.style;
52
+ writeCorrections(files.place(p.key), c);
53
+ }
54
+ for (const k of Object.keys(fixes)) castFix(k, undefined);
55
+ } else if (scope === 'place') {
56
+ const c = readCorrections(files.place(placeKey));
57
+ c.style = style;
58
+ fs.mkdirSync(files.place(placeKey), { recursive: true });
59
+ writeCorrections(files.place(placeKey), c);
60
+ } else {
61
+ for (const k of key ? [key] : castKeys(world, files)) castFix(k, style);
62
+ }
63
+ if (scope !== 'place') files.write(castFixesFile(files), fixes);
64
+
65
+ if (plan.places.length) {
66
+ if (scope === 'place') await run('build', [slug, '--from', 'paint', '--to', 'verify', '--place', placeKey]);
67
+ else await run('build', [slug, '--from', 'paint', '--to', 'reactions']);
68
+ // Icons cut from a painting are cut again from the new one; painted ones are painted in the new style.
69
+ if (scope === 'place') await run('icons', [slug]);
70
+ }
71
+ if (plan.cast.length) {
72
+ console.log('\n# cast');
73
+ await run('cast', [slug, ...plan.cast]);
74
+ for (const k of plan.models) {
75
+ console.log(`\n# model ${k}`);
76
+ await run('model', [slug, k]);
77
+ }
78
+ for (const k of plan.sprites) {
79
+ console.log(`\n# sprites ${k}`);
80
+ await run('sprites', [slug, k]);
81
+ }
82
+ }
83
+ if (plan.faces.length) await run('faces', [slug, ...plan.faces]);
84
+ if (scope === 'world' && plan.places.length) await run('build', [slug, '--from', 'verify', '--to', 'verify']);
85
+ console.log(`\nstyle ${slug}: ${scope}${key ? ` ${key}` : ''} now "${style}"`);
86
+ },
87
+ });
@@ -0,0 +1,45 @@
1
+ // diorama verify <slug> [place...]: verify.json per place; exits 1 when a place has errors.
2
+ import fs from 'node:fs';
3
+ import { Geometry } from '../play/geometry.ts';
4
+ import { defineCommand } from '../registry.ts';
5
+ import { gapError, placeGap } from './build.ts';
6
+ import { verifyPlace, type BackdropNotes, type MaskNotes, type PlateNotes } from '../scene/verify.ts';
7
+ import { WorldFiles } from '../world/files.ts';
8
+ import { checkPlaceOcclusion, occlusionFindings } from '../scene/occlusion-check.ts';
9
+ import { Reactions } from '../play/reactions.ts';
10
+ import { reactionInput } from './reactions.ts';
11
+ import { reactionSource } from '../scene/reactions.ts';
12
+
13
+ defineCommand({
14
+ name: 'verify',
15
+ usage: 'verify <slug> [place...] check each place can be walked and used (verify.json)',
16
+ async run([slug, ...only]) {
17
+ if (!slug) throw new Error('usage: diorama verify <slug> [place...]');
18
+ const files = new WorldFiles(slug);
19
+ const world = files.world();
20
+ let failed = 0;
21
+ for (const place of world.places.filter((p) => only.length === 0 || only.includes(p.key))) {
22
+ const at = (...parts: string[]) => files.place(place.key, ...parts);
23
+ const gap = placeGap(files.place(place.key), 'verify');
24
+ if (gap) throw new Error(gapError(place.key, gap));
25
+ const geometry = Geometry.parse(files.readJson(at('geometry.json')));
26
+ const plates = (fs.existsSync(at('plates.json')) ? files.readJson(at('plates.json')) : {}) as PlateNotes;
27
+ const backdrop = (fs.existsSync(at('backdrop.json')) ? files.readJson(at('backdrop.json')) : {}) as BackdropNotes;
28
+ const verdict = verifyPlace(place, geometry, files.readJson(at('masks.json')) as MaskNotes, plates, backdrop);
29
+ // Who the painting hides where, by the depth map: also the things nobody named (a lamp post, a bridge's side).
30
+ verdict.flags.push(...occlusionFindings(geometry, await checkPlaceOcclusion(files.place(place.key), geometry)));
31
+ if (fs.existsSync(at('reactions.json'))) {
32
+ try {
33
+ const rules = Reactions.parse(files.readJson(at('reactions.json')));
34
+ if (rules.source !== reactionSource(reactionInput(files, place.key))) verdict.flags.push({ code: 'REACTIONS_STALE', detail: 'Scene or story changed; rerun reactions. Stale effects are disabled in play.' });
35
+ } catch (e) { verdict.flags.push({ code: 'REACTIONS_INVALID', detail: `Rerun reactions: ${e instanceof Error ? e.message : String(e)}` }); }
36
+ }
37
+ files.write(at('verify.json'), verdict);
38
+ if (!verdict.ok) failed++;
39
+ console.log(`${place.key}: ${verdict.ok ? 'ok' : `${verdict.errors.length} error(s)`}, ${verdict.flags.length} flag(s)`);
40
+ for (const e of verdict.errors) console.log(` ERROR ${e.code}${e.key ? ` ${e.key}` : ''}: ${e.detail}`);
41
+ for (const f of verdict.flags) console.log(` flag ${f.code}${f.key ? ` ${f.key}` : ''}: ${f.detail}`);
42
+ }
43
+ if (failed) process.exitCode = 1;
44
+ },
45
+ });
@@ -0,0 +1,14 @@
1
+ import { defineCommand } from '../registry.ts';
2
+ import { WorldFiles } from '../world/files.ts';
3
+ import { soundQuality } from '../sound/cost.ts';
4
+ import { retryVoiceLine } from '../sound/retry.ts';
5
+
6
+ defineCommand({
7
+ name: 'voice-retry',
8
+ usage: 'voice-retry <slug> <speaker> "<exact text>" retry one line without changing its voice or other recordings',
9
+ async run([slug, speaker, text]) {
10
+ if (!slug || !speaker || !text) throw new Error('usage: voice-retry <slug> <speaker> "<exact text>"');
11
+ process.env.DIORAMA_SOUND = soundQuality();
12
+ await retryVoiceLine(new WorldFiles(slug), speaker, text);
13
+ },
14
+ });