@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,679 @@
1
+ // The build as it happens: one progress line in seven phases, each named for the model that does it; a stage
2
+ // where the place being worked on is painted, cut out, measured in depth and laid out for walking, picture over
3
+ // picture; the story growing beside it; and every place as a strip below. Everything comes from world_status,
4
+ // the live feed (step, call and file events) and the story, icons and cast actions the other tabs read too.
5
+ import { signal } from '@preact/signals';
6
+ import { useEffect, useRef, useState } from 'preact/hooks';
7
+ import { call, fileUrl } from './api.ts';
8
+ import { timeLeft } from '../src/world/schedule.ts';
9
+ import type { ExtensionProgress } from '../src/world/extension-progress.ts';
10
+
11
+ export type Layer = { file: string; v: number | null };
12
+ export type LayerName = 'blockin' | 'first' | 'painting' | 'masks' | 'depth' | 'walk';
13
+ export type StagePlace = {
14
+ key: string;
15
+ shows: string;
16
+ things: Array<{ key: string; role: string }>;
17
+ layers?: Partial<Record<LayerName, Layer>>;
18
+ image: Layer | null;
19
+ verdict: { ok: boolean; errors: unknown[] } | null;
20
+ /** Why the place is not playable, and the step Fix it rebuilds it from (null: Fix it asks the agent). */
21
+ trouble?: { why: string; from: string | null } | null;
22
+ };
23
+
24
+ /** The build's steps in seven phases, each with what does the work. Meta's models first, by name. */
25
+ export const PHASES = [
26
+ { key: 'story', title: 'Story', by: ['Muse Spark'], steps: ['world', 'story'] },
27
+ { key: 'paint', title: 'Paint', by: ['Muse Image', 'Muse Spark'], steps: ['layout', 'paint'] },
28
+ { key: 'cut', title: 'Cut out', by: ['SAM 3.1', 'Muse Image'], steps: ['masks', 'plates', 'icons'] },
29
+ { key: 'space', title: 'Depth and walk', by: ['Depth Anything', 'code'], steps: ['derive', 'backdrop', 'weather', 'reactions'] },
30
+ { key: 'cast', title: 'Cast', by: ['Muse Image', '3D'], steps: ['cast', 'model', 'sprites', 'faces'] },
31
+ { key: 'sound', title: 'Sound', by: ['ElevenLabs'], steps: ['sound', 'voices'] },
32
+ { key: 'verify', title: 'Checks', by: ['code'], steps: ['verify'] },
33
+ ] as const;
34
+
35
+ /** memo/calls.jsonl's model names as people know them. */
36
+ export const MODEL_NAME: Record<string, string> = { spark: 'Muse Spark', muse: 'Muse Image', sam: 'SAM 3.1', sound: 'ElevenLabs', music: 'ElevenLabs', voice: 'ElevenLabs', space: '3D' };
37
+ const META = new Set(['Muse Spark', 'Muse Image', 'SAM 3.1', 'Muse Voice']);
38
+ const tone = (by: string) => ({ 'Muse Spark': 'spark', 'Muse Image': 'image', 'SAM 3.1': 'sam' })[by] ?? 'other';
39
+
40
+ /** A model another than Meta's, by a short name: what a call's `via` or Setup's pick names. */
41
+ export function viaName(via: string): string {
42
+ if (via === 'muse-voice') return 'Muse Voice';
43
+ if (/claude/i.test(via)) return 'Claude';
44
+ if (/banana/i.test(via)) return 'Nano Banana';
45
+ if (/gemini|google/i.test(via)) return 'Gemini';
46
+ return via.replace(/^openrouter:/, '');
47
+ }
48
+
49
+ /** Who stands in for Spark and Muse Image (Setup's pick); the labels follow it. */
50
+ export const standIn = signal<{ spark: string | null; image: string | null }>({ spark: null, image: null });
51
+
52
+ /** A model's name as it works now: "Muse Spark" reads "Claude" while Claude answers Spark's asks. */
53
+ export const nowBy = (by: string) => (by === 'Muse Spark' && standIn.value.spark) || (by === 'Muse Image' && standIn.value.image) || by;
54
+
55
+ /** Words that name a model ("Muse Image · SAM 3.1", "1 Muse Image call", "Muse paints…") as they work now. */
56
+ export function named(text: string): string {
57
+ const { spark, image } = standIn.value;
58
+ if (!spark && !image) return text;
59
+ let out = text;
60
+ if (spark) out = out.replace(/\bMuse Spark\b/g, spark).replace(/\bSpark\b(?! names)/g, spark);
61
+ // A bare "Muse" is the painter ("Muse paints", "1 Muse edit"); Muse Voice (transcribing) never stands in.
62
+ if (image) out = out.replace(/\bMuse Image\b/g, image).replace(/\bMuse\b(?! (?:Voice|Spark|Image))/g, image);
63
+ return out;
64
+ }
65
+
66
+ /** memo/calls.jsonl's model and via as people know them. */
67
+ export const callBy = (model: string, via?: unknown) => (typeof via === 'string' && via !== 'contributor' ? viaName(via) : MODEL_NAME[model] ?? model);
68
+
69
+ export function ModelTag({ by }: { by: string }) {
70
+ const name = nowBy(by);
71
+ return <span class={`model ${tone(by)}${META.has(name) ? ' meta' : ''}`}>{name}</span>;
72
+ }
73
+
74
+ const clock = (ms: number) => {
75
+ const t = Math.max(0, Math.round(ms / 1000));
76
+ return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
77
+ };
78
+
79
+ export type Live = {
80
+ /** The steps of the running job (build.start, else its arguments), done ones with their seconds, the running one. */
81
+ plan: string[];
82
+ done: Record<string, number>;
83
+ running: Record<string, 'run' | 'fail'>;
84
+ /** When each running step began (ms), to tell a place's new picture from its old one. */
85
+ since: Record<string, number>;
86
+ /** Calls sent per model since the job began, and the last one. */
87
+ calls: Record<string, number>;
88
+ lastCall: { by: string; label: string; at: number } | null;
89
+ /** Per place, the things SAM has cut out so far in this job (from its `mask:place/thing` calls). */
90
+ cut: Record<string, string[]>;
91
+ tail: string;
92
+ extension?: ExtensionProgress;
93
+ /** Places this job set aside, by the step that failed for them (place.fail): that step is green for the others only. */
94
+ aside?: Record<string, string>;
95
+ /** Per place, the step this job is on with it (place.step events). */
96
+ places?: PlaceRuns;
97
+ };
98
+
99
+ type JobLike = { label: string; by: string; state: string; startedAt?: string; args?: string[]; extension?: ExtensionProgress };
100
+
101
+ const EXTENSION_PHASES = [
102
+ { key: 'plan', title: 'Plan and story' },
103
+ { key: 'places', title: 'Places and characters' },
104
+ { key: 'audio', title: 'Sound and items' },
105
+ { key: 'install', title: 'Apply changes' },
106
+ ] as const;
107
+
108
+ export function extensionDetail(progress?: ExtensionProgress, step?: string): string {
109
+ if (!progress) return step ? `Working: ${step}` : 'Working; waiting for progress';
110
+ if (progress.phase === 'places' && progress.task === 'characters') return 'Preparing new characters';
111
+ if (progress.phase === 'places' && progress.place) return `Place ${progress.index} of ${progress.total}: ${progress.place}${step ? ` · ${step}` : ''}`;
112
+ return ({ plan: 'Planning and checking the story', places: 'Building places', audio: 'Preparing sound and items', install: 'Applying the checked changes' })[progress.phase];
113
+ }
114
+
115
+ /**
116
+ * The one progress display: phases as a bar, each as wide as it usually takes, filled as its steps finish.
117
+ * Idle, it shows which steps the world has; running, the time and what is left, from the last build's step
118
+ * times (world_status.typical), never a made-up countdown.
119
+ */
120
+ /** What each step leaves on the stage, for a click on its segment: the place layer it makes. */
121
+ /** Step names as people say them, for the build bar's segments. */
122
+ export const STEP_TITLE: Record<string, string> = {
123
+ world: 'Places plan', story: 'Story', layout: 'Plan sketch', paint: 'Painting', masks: 'Cut out things', plates: 'Change plates', icons: 'Item icons',
124
+ derive: 'Walkable floor', backdrop: 'Depth', weather: 'Weather', reactions: 'Reactions', cast: 'Characters', model: '3D models', sprites: 'Sprites',
125
+ faces: 'Talking faces', sound: 'Sounds', voices: 'Voices', verify: 'Checks',
126
+ };
127
+ /** Who does each step, and what it does, in a line: the tip on a step still to come. */
128
+ const STEP_BY: Record<string, string> = {
129
+ world: 'Muse Spark', story: 'Muse Spark', layout: 'Muse Spark', paint: 'Muse Image', masks: 'SAM 3.1', plates: 'Muse Image', icons: 'Muse Image',
130
+ derive: 'Depth Anything', backdrop: 'Muse Image', weather: 'code', reactions: 'Muse Spark', cast: 'Muse Image', model: '3D', sprites: 'Muse Image',
131
+ faces: 'Muse Image', sound: 'ElevenLabs', voices: 'ElevenLabs', verify: 'code',
132
+ };
133
+ const STEP_DOES: Record<string, string> = {
134
+ world: 'plans the places, who lives there and how they connect',
135
+ story: 'writes the story, the dialogue and the endings over the places plan',
136
+ layout: 'sketches each place: where every thing, light, door and floor goes',
137
+ paint: 'paints each place over its plan sketch, in the world\'s style',
138
+ masks: 'cuts every thing and the walkable floor out of each painting',
139
+ plates: 'paints what a place looks like when something changes there (a door opens, an item is taken)',
140
+ icons: 'draws an icon for every item the hero can pick up',
141
+ derive: 'measures depth, scale and where the hero can walk in each place',
142
+ backdrop: 'paints what is hidden behind the cut-out things, so they can move in parallax',
143
+ weather: 'adds weather and moving light: rain, fog, glows, water',
144
+ reactions: 'decides how things react when the hero touches or uses them',
145
+ cast: 'paints every character once, standing, from the story\'s looks',
146
+ model: 'turns each character into a 3D body with a skeleton',
147
+ sprites: 'renders each character walking and talking from every side',
148
+ faces: 'prepares talking faces for the speaking characters',
149
+ sound: 'makes the ambience and the sound effects of each place',
150
+ voices: 'casts and records a voice for every spoken line',
151
+ verify: 'plays every place and every ending to check the game can be finished',
152
+ };
153
+
154
+ /** What a click on a step's segment opens. */
155
+ const STEP_OPENS: Record<string, string> = {
156
+ world: 'the places plan (the story is written over it)', story: 'the story', layout: 'the plan sketch Muse paints over', paint: 'the painting',
157
+ masks: 'what SAM cut out', plates: 'each thing that is taken or changes, and the place after', icons: 'every item: where SAM cuts it from and its icon',
158
+ derive: 'the walkable floor', backdrop: 'the depth map', weather: 'each place and the weather read for it', reactions: 'each place and what reacts there',
159
+ cast: 'each character as it is painted', model: 'the 3D bodies as they are built', sprites: 'the sprite sheets as they are painted', faces: 'the talking faces',
160
+ sound: 'every sound, to listen to', voices: 'each character\'s voice', verify: 'the game, or the build log',
161
+ };
162
+
163
+ export const STEP_SHOWS: Record<string, LayerName> = { layout: 'blockin', paint: 'painting', masks: 'masks', plates: 'masks', derive: 'walk', backdrop: 'depth', weather: 'painting', reactions: 'painting' };
164
+
165
+ export function Pipeline({ job, all, live, build, typical, total, onOpen }: { job?: JobLike; all: string[]; live: Live; build: Record<string, { seconds: number; status?: string; warnings?: string[] }>; typical: Record<string, number>;
166
+ /** The world's calls per model so far, every job together: the counts in the head are this job's. */
167
+ total?: Record<string, number>; onOpen?: (phase: string, step?: string) => void }) {
168
+ const [now, setNow] = useState(Date.now());
169
+ useEffect(() => {
170
+ if (!job) return;
171
+ const timer = setInterval(() => setNow(Date.now()), 1000);
172
+ return () => clearInterval(timer);
173
+ }, [!!job]);
174
+ const { plan, done, running } = live;
175
+ const asideAt = (s: string) => Object.entries(live.aside ?? {}).filter(([, step]) => step === s).map(([place]) => place);
176
+ const took = (s: string) => build[s]?.seconds ?? typical[s] ?? 30;
177
+ const state = (s: string): 'ok' | 'existing' | 'run' | 'fail' | 'todo' | 'skip' | 'warn' => {
178
+ if (running[s] === 'fail') return 'fail';
179
+ if (running[s] === 'run') return 'run';
180
+ // Talking faces are optional and come last: a face that is not ready is no build problem (the faces view says who).
181
+ if (build[s]?.status === 'incomplete' && s !== 'faces') return 'warn';
182
+ // Done for the world, but a place was set aside at this step: not green, since that place has nothing from it.
183
+ if (asideAt(s).length && (done[s] !== undefined || running[s] === undefined)) return 'warn';
184
+ if (done[s] !== undefined) return 'ok';
185
+ // A partial job does not erase the earlier world build. Keep it distinct from work finished in this job.
186
+ if (job && !plan.includes(s)) return build[s] ? 'existing' : 'skip';
187
+ return !job && build[s] ? 'ok' : 'todo';
188
+ };
189
+ const started = job?.startedAt ? Date.parse(job.startedAt) : null;
190
+ const elapsed = started ? now - started : 0;
191
+ // Several steps run at once (places side by side, cast and voices alongside): each has its own clock.
192
+ const runStep = Object.keys(running).find((s) => running[s] === 'run');
193
+ const runFor = (s: string) => (running[s] === 'run' && live.since[s] ? now - live.since[s] : 0);
194
+ // Time left is the longest chain of what is still to do along the build's web, not the sum of every step.
195
+ const left = timeLeft(plan, (s) => (done[s] !== undefined ? 0 : Math.max(0, took(s) * 1000 - runFor(s))));
196
+ const known = plan.length > 0 && plan.every((s) => done[s] !== undefined || build[s] || typical[s] !== undefined);
197
+ const when = !job ? null : job.state === 'queued' ? 'waiting for the job before it'
198
+ : `${clock(elapsed)}${!known ? '' : left > 30_000 ? ` · about ${Math.max(1, Math.round(left / 60_000))} min left` : runStep && left === 0 ? ' · longer than last time' : ' · almost done'}`;
199
+ const phases = PHASES.map((p) => ({ ...p, steps: p.steps.filter((s) => all.includes(s)) })).filter((p) => p.steps.length);
200
+ const weight = (p: (typeof phases)[number]) => p.steps.reduce((sum, s) => sum + took(s), 0);
201
+ const calls = Object.entries(live.calls).filter(([, n]) => n > 0).sort(([a], [b]) => Number(META.has(b)) - Number(META.has(a)));
202
+ const fresh = live.lastCall && now - live.lastCall.at < 4000;
203
+ if (job?.args?.[0] === 'extend') {
204
+ const progress = live.extension ?? job.extension;
205
+ const active = progress ? EXTENSION_PHASES.findIndex(p => p.key === progress.phase) : -1;
206
+ const failed = job.state === 'failed' || job.state === 'cancelled';
207
+ return <section class="pipeline on extension-pipeline" aria-live="polite">
208
+ <div class="row head"><strong>{job.by === 'agent' ? 'Agent: ' : ''}{job.label}</strong><span class="detail tail">{failed ? 'Build did not finish' : extensionDetail(progress, runStep)}</span><span class="detail when">{clock(elapsed)}</span></div>
209
+ <ol class="phases" aria-label="Extension progress">
210
+ {EXTENSION_PHASES.map((p, i) => {
211
+ const phase = active < 0 ? 'todo' : i < active ? 'ok' : i === active ? failed ? 'fail' : 'run' : 'todo';
212
+ return <li class={phase} style={{ flexGrow: 1 }} aria-label={`${p.title}: ${phase === 'ok' ? 'done' : phase === 'run' ? 'in progress' : phase === 'fail' ? 'failed' : 'waiting'}`}><span class="bar"><span class="fill" style={{ width: phase === 'todo' ? '0%' : '100%' }} /></span><span class="label"><span class="name">{p.title}</span></span></li>;
213
+ })}
214
+ </ol>
215
+ <span class="detail">The current world is shown; changes appear after the build succeeds. Finished steps do not confirm visual quality or a playthrough.</span>
216
+ </section>;
217
+ }
218
+ return (
219
+ <section class={`pipeline${job ? ' on' : ''}`} aria-live="polite">
220
+ <div class="row head">
221
+ <strong>{job ? `${job.by === 'agent' ? 'Agent: ' : ''}${job.label}` : 'Build'}</strong>
222
+ {/* This job's calls, with the world's total beside it only when earlier jobs added to it: "136 of 136" read as
223
+ a progress bar that was always full, while it only said this job made every call the world has. */}
224
+ {job && calls.length > 0 && <span class="detail calls-kicker" title="Model calls this job made. The number after 'of' is the whole world so far, every build and agent job together">this job</span>}
225
+ {job && calls.map(([by, n]) => <span class={`count ${tone(by)}${fresh && live.lastCall?.by === by ? ' hot' : ''}`} title={`${nowBy(by)}: ${n} call${n === 1 ? '' : 's'} sent in this job (not a target: it grows with every take and check)${(total?.[by] ?? 0) > n ? `, ${total![by]} for this world so far` : ''}`}><ModelTag by={by} /> {n}{(total?.[by] ?? 0) > n ? <span class="of"> · {total![by]} in all</span> : null}</span>)}
226
+ {/* A step's raw line is cut to what it is doing ("barn-interior layout try 1"); the whole line is on hover. */}
227
+ <span class="detail tail" title={job ? live.tail : undefined}>{job ? (fresh && live.lastCall ? `${nowBy(live.lastCall.by)}: ${live.lastCall.label}` : live.tail.split(/: |[.!?]\s/)[0].slice(0, 60)) : ''}</span>
228
+ {when && <span class="detail when">{when}</span>}
229
+ </div>
230
+ <ol class="phases" aria-label="Build phases">
231
+ {phases.map((p) => {
232
+ const states = p.steps.map(state);
233
+ const all = weight(p);
234
+ const phase = states.includes('fail') ? 'fail' : states.includes('run') ? 'run' : states.includes('warn') ? 'warn' : states.every((x) => x === 'ok') ? 'ok' : states.every((x) => x === 'ok' || x === 'existing') ? 'existing' : states.every((x) => x === 'skip') ? 'skip' : 'todo';
235
+ // Something to show once a step has run (or runs now); a click puts it on the stage.
236
+ // A phase only partly done (sprites still to come) shows what its done steps made too.
237
+ const open = onOpen && states.some((x) => x === 'ok' || x === 'existing' || x === 'run' || x === 'fail' || x === 'warn');
238
+ const tip = `${p.title} · ${p.by.map(nowBy).join(', ')}\n` + p.steps.map((s, i) => `${states[i] === 'ok' ? '✓' : states[i] === 'existing' ? '✓' : states[i] === 'run' ? '…' : states[i] === 'fail' ? '✕' : states[i] === 'warn' ? '!' : '·'} ${STEP_TITLE[s] ?? s}${states[i] === 'existing' ? ' (previous build)' : done[s] !== undefined ? ` ${Math.round(done[s])}s` : ''}${asideAt(s).length ? `: failed for ${asideAt(s).join(', ')} (set aside; later steps skip it)` : ''}`).join('\n');
239
+ let at = 0;
240
+ return (
241
+ <li class={`${phase}${open ? ' open' : ''}`} style={{ flexGrow: Math.max(1, all) }}>
242
+ <span class="bar">{p.steps.map((s, i) => {
243
+ const before = p.steps.slice(0, i).reduce((sum, step) => sum + took(step), 0);
244
+ const amount = states[i] === 'ok' || states[i] === 'existing' || states[i] === 'fail' || states[i] === 'warn' ? took(s) : states[i] === 'run' ? Math.min(took(s) * 0.95, runFor(s) / 1000) : 0;
245
+ return <span class={`fill ${states[i]}`} style={{ left: `${100 * before / Math.max(1, all)}%`, width: `${100 * amount / Math.max(1, all)}%` }} />;
246
+ })}
247
+ {p.steps.slice(1).map((s) => <span class="tick" style={{ left: `${(100 * p.steps.slice(0, p.steps.indexOf(s)).reduce((a, x) => a + took(x), 0)) / Math.max(1, all)}%` }} />)}
248
+ {p.steps.map((s, i) => {
249
+ const from = at;
250
+ at += took(s);
251
+ const ready = !!open && (states[i] === 'ok' || states[i] === 'existing' || states[i] === 'run' || states[i] === 'warn');
252
+ // A step still to come (or skipped) says what it will do, who does it and about how long.
253
+ if (!ready) return <span class={`seg later ${states[i]}`} style={{ left: `${(100 * from) / Math.max(1, all)}%`, width: `${(100 * took(s)) / Math.max(1, all)}%` }}
254
+ title={named(`${STEP_TITLE[s] ?? s}${STEP_BY[s] && STEP_BY[s] !== 'code' ? ` · ${STEP_BY[s]}` : ''}: ${states[i] === 'fail' ? 'failed' : states[i] === 'skip' ? 'not part of this build' : job ? 'not started yet' : 'not built yet'}.\n${STEP_DOES[s] ? `${STEP_DOES[s][0].toUpperCase()}${STEP_DOES[s].slice(1)}.` : ''}${states[i] !== 'skip' && took(s) ? `\nUsually about ${took(s) >= 90 ? `${Math.round(took(s) / 60)} min` : `${Math.max(5, Math.round(took(s) / 5) * 5)} s`}.` : ''}`)} />;
255
+ return <button type="button" class="seg" style={{ left: `${(100 * from) / Math.max(1, all)}%`, width: `${(100 * took(s)) / Math.max(1, all)}%` }}
256
+ title={`${STEP_TITLE[s] ?? s} (${s})${states[i] === 'run' ? ': working now, not finished' : asideAt(s).length ? `: failed for ${asideAt(s).join(', ')}, set aside (later steps skip it); done for the other places` : states[i] === 'existing' ? ': from the earlier build' : done[s] !== undefined ? `: done in ${Math.round(done[s])} s` : ': done'}.\n${s === 'faces' && build.faces?.status === 'incomplete' && states[i] !== 'run' ? `Optional: ${(() => { const who = [...new Set((build.faces.warnings ?? []).map((w) => /^([a-z0-9][\w-]*):/.exec(w)?.[1]).filter(Boolean))]; return who.length ? `${who.join(', ')} talk with gestures, no moving lips yet` : 'some talk with gestures, no moving lips yet'; })()}.\n` : ''}Click to see ${STEP_OPENS[s] ?? 'what it made'}${states[i] === 'run' ? ' (what it works from, until it is ready)' : ''}`} onClick={() => onOpen!(p.key, s)} />;
257
+ })}
258
+ </span>
259
+ {open
260
+ ? <button type="button" class="label" title={`${tip}\nClick to see what it made`} onClick={() => onOpen!(p.key)}><span class="name">{p.title}</span>{p.by[0] !== 'code' && <ModelTag by={p.by[0]} />}</button>
261
+ : <span class="label" title={tip}><span class="name">{p.title}</span>{p.by[0] !== 'code' && <ModelTag by={p.by[0]} />}</span>}
262
+ </li>
263
+ );
264
+ })}
265
+ </ol>
266
+ {job && <span class="detail">
267
+ Each bar is split into its steps. Green: done now · faded green: kept from the earlier build · orange: working now{phases.some((p) => p.steps.some((x) => state(x) === 'warn')) ? ' · striped: done, but not for every place (hover it for which)' : ''}. Built steps do not confirm visual quality or a playthrough.
268
+ </span>}
269
+ </section>
270
+ );
271
+ }
272
+
273
+ /** What a place step leaves behind, and who makes it. */
274
+ const STEP_LAYER: Record<string, { layer: LayerName; doing: string; by: string }> = {
275
+ layout: { layer: 'blockin', doing: 'plans where every thing, light and floor goes', by: 'Muse Spark' },
276
+ paint: { layer: 'painting', doing: 'paints over the plan sketch', by: 'Muse Image' },
277
+ masks: { layer: 'masks', doing: 'cuts out every thing and the floor', by: 'SAM 3.1' },
278
+ derive: { layer: 'walk', doing: 'measures depth, scale and where to walk', by: 'Depth Anything' },
279
+ };
280
+ /** The pictures in the order the build lays them on, with what each one says. */
281
+ const SHOWN: Array<{ layer: LayerName; title: string; by: string; enter: string }> = [
282
+ { layer: 'blockin', title: 'Plan', by: 'Muse Spark', enter: 'fade' },
283
+ { layer: 'painting', title: 'Painting', by: 'Muse Image', enter: 'wipe' },
284
+ { layer: 'masks', title: 'Things', by: 'SAM 3.1', enter: 'iris' },
285
+ { layer: 'depth', title: 'Depth', by: 'Depth Anything', enter: 'sweep' },
286
+ { layer: 'walk', title: 'Walk grid', by: 'code', enter: 'fade' },
287
+ ];
288
+
289
+ const has = (p: StagePlace, l: LayerName) => !!p.layers?.[l];
290
+ /** The step working on this place right now whose picture is not out yet, if any. */
291
+ export function working(p: StagePlace, live: Live) {
292
+ // The build says per place which step it is on: trusted over the guess from the running steps below.
293
+ const own = live.places?.[p.key];
294
+ if (own) return !own.done && own.ok === undefined && STEP_LAYER[own.step] ? { step: own.step, ...STEP_LAYER[own.step] } : null;
295
+ const step = Object.keys(live.running).find((s) => live.running[s] === 'run' && STEP_LAYER[s]);
296
+ if (!step) return null;
297
+ const { layer } = STEP_LAYER[step];
298
+ const v = p.layers?.[layer]?.v ?? 0;
299
+ return v < (live.since[step] ?? 0) ? { step, ...STEP_LAYER[step] } : null;
300
+ }
301
+
302
+ /** depth.png (rows below the horizon, R high byte, G low byte) as a colour picture: near warm, far cool. */
303
+ function DepthLayer({ src, class: cls }: { src: string; class: string }) {
304
+ const canvas = useRef<HTMLCanvasElement>(null);
305
+ useEffect(() => {
306
+ const img = new Image();
307
+ img.onload = () => {
308
+ const c = canvas.current;
309
+ if (!c) return;
310
+ [c.width, c.height] = [img.width, img.height];
311
+ const g = c.getContext('2d', { willReadFrequently: true })!;
312
+ g.drawImage(img, 0, 0);
313
+ const px = g.getImageData(0, 0, c.width, c.height);
314
+ const d = px.data;
315
+ // Ranked, not scaled: most of a picture sits at a few distances, and the ramp should span them all.
316
+ const n = c.width * c.height;
317
+ const raw = new Uint16Array(n);
318
+ for (let i = 0; i < n; i++) raw[i] = (d[i * 4] << 8) | d[i * 4 + 1];
319
+ const sorted = raw.slice().sort();
320
+ const rank = (v: number) => {
321
+ let [lo, hi] = [0, n];
322
+ while (lo < hi) (sorted[(lo + hi) >> 1] < v ? (lo = ((lo + hi) >> 1) + 1) : (hi = (lo + hi) >> 1));
323
+ return lo / n;
324
+ };
325
+ const ramp = new Map<number, number>();
326
+ for (let i = 0; i < n; i++) {
327
+ // 0 far (deep blue), 1 near (amber): a three-stop ramp.
328
+ const t = ramp.get(raw[i]) ?? (ramp.set(raw[i], rank(raw[i])), ramp.get(raw[i])!);
329
+ const [a, b, u] = t < 0.5 ? [[28, 36, 120], [60, 170, 180], t * 2] : [[60, 170, 180], [255, 196, 90], (t - 0.5) * 2];
330
+ d.set([a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u, 255], i * 4);
331
+ }
332
+ g.putImageData(px, 0, 0);
333
+ };
334
+ img.src = src;
335
+ }, [src]);
336
+ return <canvas ref={canvas} class={cls} />;
337
+ }
338
+
339
+ /**
340
+ * The big picture: one place, its pictures stacked in build order, the newest sliding in over the last.
341
+ * While a step works on it, a scan line and the model's name; once built, it turns through its layers.
342
+ */
343
+ export function Stage({ slug, place, live, building, hold }: { slug: string; place: StagePlace; live: Live; building: boolean; hold?: { layer: LayerName; at: number } | null }) {
344
+ const shown = SHOWN.filter((s) => has(place, s.layer));
345
+ const newest = shown.at(-1)?.layer ?? null;
346
+ const [pick, setPick] = useState<LayerName | null>(null);
347
+ const [turn, setTurn] = useState(0);
348
+ const [hover, setHover] = useState(false);
349
+ // Built and nobody looking at one layer: show them in turn, painting longest.
350
+ useEffect(() => {
351
+ if (building || pick || hover || shown.length < 3) return;
352
+ const timer = setTimeout(() => setTurn((t) => t + 1), turn % shown.length === 0 ? 5000 : 2600);
353
+ return () => clearTimeout(timer);
354
+ }, [building, pick, hover, shown.length, turn]);
355
+ // A click on the build bar holds the layer its step made, on this place and the next the stage moves to.
356
+ const held = useRef<LayerName | null>(null);
357
+ // Asked for a layer this place does not have yet (its step still works): say so, and switch to it once it lands.
358
+ const [want, setWant] = useState<LayerName | null>(null);
359
+ useEffect(() => void (hold && (held.current = hold.layer, setWant(hold.layer), setPick(has(place, hold.layer) ? hold.layer : newest))), [hold?.at]);
360
+ const ready = !!want && has(place, want);
361
+ useEffect(() => void (want && ready && (setPick(want), setWant(null))), [want, ready]);
362
+ const wanted = want && !ready ? SHOWN.find((s) => s.layer === want) : undefined;
363
+ useEffect(() => (setPick(held.current && has(place, held.current) ? held.current : null), setTurn(0)), [place.key]);
364
+ const cycle = ['painting', ...shown.map((s) => s.layer).filter((l) => l !== 'painting' && l !== 'blockin')] as LayerName[];
365
+ const top: LayerName | null = pick ?? (building || shown.length < 3 ? newest : cycle[turn % cycle.length]);
366
+ const upTo = shown.findIndex((s) => s.layer === top);
367
+ const busy = building ? working(place, live) : null;
368
+ const caption = busy ? { by: busy.by, text: `${busy.doing}…` } : top ? { by: SHOWN.find((s) => s.layer === top)!.by, text: SHOWN.find((s) => s.layer === top)!.title } : null;
369
+ const base = place.layers?.painting ?? place.layers?.blockin;
370
+ const failed = !!live.aside?.[place.key] || (!building && !!place.trouble);
371
+ return (
372
+ <figure class="stage-live" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
373
+ <div class="frame">
374
+ {/* Not painted yet: the place's name and who paints it; what it shows is in the tooltip and the story beside. */}
375
+ {shown.length === 0 && <div class="empty waiting" title={place.shows}><strong>{place.key}</strong><span><ModelTag by="Muse Image" /> {busy ? `${busy.doing}…` : 'paints it next'}</span></div>}
376
+ {/* The depth map needs a painting under it; the rest are whole pictures, each over the one before. */}
377
+ {shown.slice(0, upTo + 1).map((s) => {
378
+ const l = place.layers![s.layer]!;
379
+ const cls = `layer enter-${s.enter}`;
380
+ if (s.layer === 'depth') return [base && <img key={`${s.layer}-base`} class="layer" src={fileUrl(slug, base.file, base.v)} alt="" />, <DepthLayer key={`${s.layer}-${l.v}`} class={`${cls} depth`} src={fileUrl(slug, l.file, l.v)} />];
381
+ return <img key={`${s.layer}-${l.v}`} class={cls} src={fileUrl(slug, l.file, l.v)} alt={`${place.key}: ${s.title}`} />;
382
+ })}
383
+ {/* Cutting out: Spark's boxes for each thing, filled in as SAM returns its mask. Real progress, not a sweep. */}
384
+ {busy?.step === 'masks' && <Finds slug={slug} place={place.key} cut={live.cut[place.key] ?? []} />}
385
+ {busy && <div class={`scan ${busy.step}`} />}
386
+ {caption && (
387
+ <figcaption>
388
+ <ModelTag by={caption.by} />
389
+ <span>{caption.text}</span>
390
+ </figcaption>
391
+ )}
392
+ <span class="where">{place.key}</span>
393
+ {/* The layer tabs, then what is missing or wrong under them, so neither covers the other. */}
394
+ <div class="over">
395
+ {shown.length > 1 && (
396
+ <div class="layers" role="tablist" aria-label="Layers">
397
+ {shown.map((s) => (
398
+ <button type="button" role="tab" aria-selected={top === s.layer} class={`ghost small${top === s.layer ? ' on' : ''}`} title={named(`${LAYER_TIPS[s.layer] ?? s.title}, by ${s.by}. Click to hold it, again to let the stage cycle`)} onClick={() => ((held.current = null), setWant(null), setPick(pick === s.layer ? null : s.layer))}>{s.title}</button>
399
+ ))}
400
+ </div>
401
+ )}
402
+ {/* Red only for a real failure: a step this build set aside, or trouble once no build runs; a place the
403
+ running build has not reached yet is just not made yet. */}
404
+ {failed && <span class="wanted trouble" role="status">{live.aside?.[place.key] ? `${STEP_TITLE[live.aside[place.key]] ?? live.aside[place.key]} failed for this place: set aside, so the steps after it skip it. ` : ''}{!building && place.trouble ? `${place.trouble.why} · Fix it below the stage` : ''}</span>}
405
+ {!failed && wanted && <span class="wanted" role="status">{wanted.title}: not made yet for this place{top ? `; showing ${SHOWN.find((s) => s.layer === top)!.title.toLowerCase()} until it is` : ''}</span>}
406
+ </div>
407
+ </div>
408
+ </figure>
409
+ );
410
+ }
411
+
412
+ const LAYER_TIPS: Record<string, string> = {
413
+ blockin: 'Plan: where Spark put every thing, light and floor. Muse paints over this sketch (without the names) and keeps each shape where it stands',
414
+ first: 'The first painting, before the critic\'s fixes',
415
+ painting: 'The painting Muse made',
416
+ masks: 'What SAM cut out: each thing and floor',
417
+ depth: 'Depth: how far away each spot is, as colour',
418
+ walk: 'Where the hero can walk',
419
+ };
420
+
421
+ /** On a failed or unpainted place: why in one line, and a button that rebuilds it from where it broke or asks the agent. */
422
+ function FixIt({ slug, place, why }: { slug: string; place: string; why: string }) {
423
+ const [said, setSaid] = useState<string | null>(null);
424
+ const [busy, setBusy] = useState(false);
425
+ const fix = async () => {
426
+ setBusy(true);
427
+ try {
428
+ const r = await call<{ did: 'rebuild' | 'agent'; from?: string }>('place_fix', { slug, place });
429
+ setSaid(r.did === 'rebuild' ? `Rebuilding from ${r.from}…` : 'Asked the agent to repair it');
430
+ } catch (error) {
431
+ setSaid(error instanceof Error ? error.message : String(error));
432
+ } finally {
433
+ setBusy(false);
434
+ }
435
+ };
436
+ return (
437
+ <div class="fixit">
438
+ <div class="panel">
439
+ <span class="why" title={why}>{said ?? why}</span>
440
+ {!said && <button type="button" class="small" disabled={busy} title={named('Rebuild this place from the step it broke at (a refused painting is reworded by itself). Steps before it are free; a new painting is 1 Muse Image call. If it was built and still fails, the agent repairs it')} onClick={fix}>Fix it</button>}
441
+ </div>
442
+ </div>
443
+ );
444
+ }
445
+
446
+ /** Every place as a small card: its newest picture, how far it got, and what the build is doing to it. */
447
+ export function Strip({ slug, places, focus, live, building, repairing, onPick }: { slug: string; places: StagePlace[]; focus: string | null; live: Live; building: boolean; repairing: Record<string, string>; onPick: (key: string) => void }) {
448
+ return (
449
+ <ul class="strip">
450
+ {places.map((p) => {
451
+ const top = [...SHOWN].reverse().find((s) => s.layer !== 'depth' && has(p, s.layer));
452
+ const busy = building ? working(p, live) : null;
453
+ const n = SHOWN.filter((s) => has(p, s.layer)).length;
454
+ return (
455
+ <li class={`${focus === p.key ? 'focus' : ''}${busy ? ' busy' : ''}`}>
456
+ <button type="button" class="card" title={`${p.shows}\n\nOpen this place to look at it and fix it; click again to close`} onClick={() => onPick(p.key)}>
457
+ <span class="thumb">
458
+ {top ? <img src={fileUrl(slug, p.layers![top.layer]!.file, p.layers![top.layer]!.v)} alt={p.shows} loading="lazy" /> : <span class="noimg" title={p.shows}>{busy ? `${nowBy(busy.by)}…` : 'waiting to paint'}</span>}
459
+ {busy && <span class="scan" />}
460
+ </span>
461
+ <span class="name">
462
+ <strong>{p.key}</strong>
463
+ {repairing[p.key] ? <span class="badge run" title={repairing[p.key]}>repairing</span>
464
+ : (live.aside?.[p.key] || (p.trouble && !building)) && !p.verdict ? <span class="badge fail">failed</span>
465
+ : p.verdict ? <span class={`badge ${p.verdict.ok ? 'ok' : 'fail'}`}>{p.verdict.ok ? 'playable' : `${p.verdict.errors.length} error${p.verdict.errors.length === 1 ? '' : 's'}`}</span>
466
+ : busy ? <span class="badge run">{nowBy(busy.by)}</span>
467
+ : <span class="dots" aria-label={`${n} of ${SHOWN.length} layers`}>{SHOWN.map((s) => <i class={has(p, s.layer) ? 'on' : ''} />)}</span>}
468
+ </span>
469
+ </button>
470
+ {p.trouble && !building && !repairing[p.key] && <FixIt key={p.trouble.why} slug={slug} place={p.key} why={p.trouble.why} />}
471
+ </li>
472
+ );
473
+ })}
474
+ </ul>
475
+ );
476
+ }
477
+
478
+ type Story = { intro: string; items: { key: string; name: string }[]; endings: { key: string; title: string }[]; dialogues: { npc: string }[] } | null;
479
+ type StoryStatus = { hero: string; story: Story; places: { key: string; shows: string }[] };
480
+ type Item = { key: string; name?: string; icon?: { file: string; v?: number | null } | null };
481
+ type Character = { key: string; figure?: { file: string; v?: number | null } | null };
482
+
483
+ /** The story as it is written: the idea, the hero, each place, then the intro, what to find and how it can end. */
484
+ export function Tale({ slug, version, pitch, hero, places }: { slug: string; version: number; pitch: string | null; hero?: string; places: StagePlace[] }) {
485
+ const [story, setStory] = useState<StoryStatus | null>(null);
486
+ const [items, setItems] = useState<Item[]>([]);
487
+ const [cast, setCast] = useState<Character[]>([]);
488
+ useEffect(() => {
489
+ if (!places.length) return;
490
+ call<StoryStatus>('story_status', { slug }).then(setStory).catch(() => {});
491
+ call<Item[]>('icons_status', { slug }).then(setItems).catch(() => {});
492
+ call<Character[]>('cast_status', { slug }).then(setCast).catch(() => {});
493
+ }, [slug, version, places.length]);
494
+ const s = story?.story;
495
+ const icon = (key: string) => items.find((i) => i.key === key)?.icon;
496
+ const face = (key: string) => cast.find((c) => c.key === key)?.figure;
497
+ const people = [...new Set([story?.hero ?? hero, ...(s?.dialogues.map((d) => d.npc) ?? [])].filter((k): k is string => !!k))];
498
+ return (
499
+ <aside class="tale" aria-live="polite">
500
+ <p class="kicker"><ModelTag by="Muse Spark" /> writes the world</p>
501
+ {!places.length && <p class="pitch">{pitch ?? 'The idea becomes places, a hero and a story…'}</p>}
502
+ {people.length > 0 && (
503
+ <ul class="people">
504
+ {people.map((k) => {
505
+ const pic = face(k);
506
+ return <li key={k}>{pic ? <img src={fileUrl(slug, pic.file, pic.v)} alt="" /> : <span class="noface">{k[0]}</span>}<span>{k}</span></li>;
507
+ })}
508
+ </ul>
509
+ )}
510
+ {places.length > 0 && <ol class="where-list">{places.map((p) => <li key={p.key}><strong>{p.key}</strong> {p.shows}</li>)}</ol>}
511
+ {s?.intro && <blockquote>{s.intro}</blockquote>}
512
+ {s && s.items.length > 0 && (
513
+ <ul class="finds">
514
+ {s.items.map((i) => {
515
+ const pic = icon(i.key);
516
+ return <li key={i.key}>{pic ? <img src={fileUrl(slug, pic.file, pic.v)} alt="" /> : <span class="noicon" />}<span>{i.name}</span></li>;
517
+ })}
518
+ </ul>
519
+ )}
520
+ {s && s.endings.length > 0 && <p class="endings">{s.endings.length === 1 ? 'Ending' : `${s.endings.length} endings`}: {s.endings.map((e) => e.title).join(' · ')}</p>}
521
+ </aside>
522
+ );
523
+ }
524
+
525
+ export type FeedItem = { id: number; by: string | null; text: string; at: number };
526
+
527
+ /** Ideas for the agent once a world plays: one click asks it. */
528
+ const NEXT = (place?: string) => [
529
+ 'Make it night, with warm lanterns',
530
+ 'Add gentle rain and puddles',
531
+ place ? `Add a friendly animal in ${place}` : 'Add a friendly animal',
532
+ 'Add one more place to explore',
533
+ ];
534
+
535
+ /**
536
+ * The agent's side while a build runs: what each model does right now, newest first; when the world plays, a few
537
+ * things to ask the agent next. The chat stays below, one click away.
538
+ */
539
+ export function Crew({ feed, building, playable, place, onAsk, onClose }: { feed: FeedItem[]; building: boolean; playable: boolean; place?: string; onAsk: (text: string) => void; onClose: () => void }) {
540
+ const ideas = NEXT(place);
541
+ return (
542
+ <section class={`crew${building ? ' live' : ''}`} aria-live="polite">
543
+ <div class="row">
544
+ <strong>{building ? 'Building now' : 'Try next'}</strong>
545
+ <span class="grow" />
546
+ {!building && <button type="button" class="ghost tiny" aria-label="Hide" title="Hide until the next build" onClick={onClose}>✕</button>}
547
+ </div>
548
+ {building && (
549
+ <ol class="feed">
550
+ {feed.length === 0 && <li class="detail">Starting…</li>}
551
+ {feed.slice(0, 9).map((f) => <li key={f.id} class={f.by ? 'call' : 'step'}>{f.by ? <ModelTag by={f.by} /> : <span class="ok">✓</span>}<span>{f.text}</span></li>)}
552
+ </ol>
553
+ )}
554
+ <p class="detail">{building ? 'When it is done, ask the agent:' : 'Ask the agent, one click:'}</p>
555
+ <div class="ideas">
556
+ {ideas.map((t) => <button type="button" class="ghost small" disabled={building || !playable} title={building ? 'After the build' : 'Sends this to the agent below'} onClick={() => onAsk(t)}>{t}</button>)}
557
+ </div>
558
+ </section>
559
+ );
560
+ }
561
+
562
+ type Located = { things?: Record<string, { box?: [number, number, number, number]; phrase?: string }> };
563
+
564
+ /** The things Muse Spark located on a painting, as boxes; each turns solid when SAM 3.1 has cut it out. */
565
+ function Finds({ slug, place, cut }: { slug: string; place: string; cut: string[] }) {
566
+ const [found, setFound] = useState<Located | null>(null);
567
+ // locate.json is written once Spark has looked; asked again as SAM answers, until it is there.
568
+ useEffect(() => {
569
+ if (found?.things) return;
570
+ fetch(fileUrl(slug, `places/${place}/locate.json`, Date.now()), { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).then((j) => j && setFound(j as Located)).catch(() => {});
571
+ }, [slug, place, cut.length]);
572
+ useEffect(() => setFound(null), [place]);
573
+ const things = Object.entries(found?.things ?? {}).filter(([, t]) => t.box);
574
+ if (!things.length) return null;
575
+ return (
576
+ <svg class="finds" viewBox="0 0 1200 896" preserveAspectRatio="none" aria-label={`${cut.length} of ${things.length} things cut out`}>
577
+ {things.map(([key, t], i) => {
578
+ const [x0, y0, x1, y1] = t.box!;
579
+ const done = cut.includes(key);
580
+ return (
581
+ <g key={key} class={done ? 'cut' : 'look'} style={{ animationDelay: `${i * 0.08}s` }}>
582
+ <rect x={x0 + 3} y={y0 + 3} width={Math.max(0, x1 - x0 - 6)} height={Math.max(0, y1 - y0 - 6)} rx="10" />
583
+ <text x={x0 + 14} y={y0 + 34}>{done ? '✓ ' : ''}{key}</text>
584
+ </g>
585
+ );
586
+ })}
587
+ </svg>
588
+ );
589
+ }
590
+
591
+ /** Per place, what this job did to it: the step now (done: the place waits for the next one), and its verdict once told. */
592
+ export type PlaceRuns = Record<string, { step: string; done: boolean; at: number; ok?: boolean }>;
593
+ type ChipState = { kind: 'queued' | 'working' | 'waiting' | 'repairing' | 'done' | 'failed' | 'idle'; say: string };
594
+
595
+ /** What one place is at, in a few words: from this job's own events while building, else from its last verdict. */
596
+ export function placeState(p: StagePlace, run: PlaceRuns[string] | undefined, repairing: string | undefined, building: boolean): ChipState {
597
+ if (repairing) return { kind: 'repairing', say: 'repairing' };
598
+ if (run?.ok === true) return { kind: 'done', say: 'done' };
599
+ if (run?.ok === false) return { kind: 'failed', say: `stopped at ${(STEP_TITLE[run.step] ?? run.step).toLowerCase()}` };
600
+ if (building && run && !run.done) return { kind: 'working', say: (STEP_TITLE[run.step] ?? run.step).toLowerCase() };
601
+ if (building && run) return { kind: 'waiting', say: `${(STEP_TITLE[run.step] ?? run.step).toLowerCase()} done, waits` };
602
+ if (building) return { kind: 'queued', say: 'in the queue' };
603
+ if (p.verdict?.ok && !p.trouble) return { kind: 'done', say: 'playable' };
604
+ if (p.verdict && !p.verdict.ok || p.trouble) return { kind: 'failed', say: 'not playable' };
605
+ return { kind: 'idle', say: p.image ? 'not checked yet' : 'not built' };
606
+ }
607
+
608
+ /**
609
+ * Every place as a chip: its picture, its name and where the build is with it (queued, painting now, done, stopped),
610
+ * with a count of each above. The row scrolls sideways with the wheel, the arrows or a drag, never the page,
611
+ * and keeps the place on stage in view.
612
+ */
613
+ export function BuildPlaces({ slug, places, runs, repairing, building, watched, shown, onWatch, onFollow, verb = 'keep watching it' }: {
614
+ slug: string; places: StagePlace[]; runs: PlaceRuns; repairing: Record<string, string>; building: boolean;
615
+ watched?: string; shown?: string; onWatch: (place: string) => void;
616
+ /** Back to following the build (the build room only). */
617
+ onFollow?: () => void;
618
+ verb?: string;
619
+ }) {
620
+ const list = useRef<HTMLDivElement>(null);
621
+ const [edge, setEdge] = useState({ left: false, right: false });
622
+ const states = places.map((p) => ({ p, s: placeState(p, runs[p.key], repairing[p.key], building) }));
623
+ const measure = () => {
624
+ const el = list.current;
625
+ if (el) setEdge({ left: el.scrollLeft > 2, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2 });
626
+ };
627
+ useEffect(() => {
628
+ const el = list.current;
629
+ if (!el) return;
630
+ measure();
631
+ const ro = new ResizeObserver(measure);
632
+ ro.observe(el);
633
+ // A mouse wheel scrolls the row sideways: a vertical wheel over a sideways list otherwise does nothing.
634
+ const wheel = (e: WheelEvent) => {
635
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX) || el.scrollWidth <= el.clientWidth) return;
636
+ e.preventDefault();
637
+ el.scrollBy({ left: e.deltaY, behavior: 'auto' });
638
+ };
639
+ el.addEventListener('wheel', wheel, { passive: false });
640
+ return () => (ro.disconnect(), el.removeEventListener('wheel', wheel));
641
+ }, [places.length]);
642
+ // A place picked elsewhere (or on first show) is brought into view, by scrolling this row only. Not while the
643
+ // build rotates the stage: every scroll closes any open tooltip.
644
+ const pinned = watched ?? (building && onFollow ? undefined : shown);
645
+ useEffect(() => {
646
+ const el = list.current;
647
+ const chip = pinned ? el?.querySelector<HTMLElement>(`[data-place="${CSS.escape(pinned)}"]`) : null;
648
+ if (!el || !chip) return;
649
+ const left = chip.offsetLeft - el.offsetLeft;
650
+ if (left < el.scrollLeft || left + chip.offsetWidth > el.scrollLeft + el.clientWidth) el.scrollTo({ left: Math.max(0, left - 40), behavior: 'smooth' });
651
+ }, [pinned]);
652
+ const page = (dir: number) => list.current?.scrollBy({ left: dir * list.current.clientWidth * 0.8, behavior: 'smooth' });
653
+ const count = (k: ChipState['kind']) => states.filter((x) => x.s.kind === k).length;
654
+ const sums = building
655
+ ? [['working', count('working') + count('repairing'), 'working now'], ['waiting', count('waiting'), 'between steps'], ['queued', count('queued'), 'queued'], ['done', count('done'), 'done'], ['failed', count('failed'), 'stopped']] as const
656
+ : [['done', count('done'), 'playable'], ['failed', count('failed'), 'not playable']] as const;
657
+ return (
658
+ <nav class={`build-places${building ? ' live' : ''}`} aria-label="Build places">
659
+ <div class="build-places-head">
660
+ <span class="sums">{sums.filter(([, n]) => n > 0).map(([k, n, say]) => <span class={`sum sum-${k}`}><i />{n} {say}</span>)}</span>
661
+ {building && onFollow && <button type="button" class="ghost small build-follow" aria-pressed={!watched} title="Automatically follow the places being built" onClick={onFollow}>Follow build</button>}
662
+ </div>
663
+ <div class={`build-place-row${edge.left ? ' more-left' : ''}${edge.right ? ' more-right' : ''}`}>
664
+ {edge.left && <button type="button" class="ghost scroll-arrow left" aria-label="Earlier places" onClick={() => page(-1)}>‹</button>}
665
+ <div class="build-place-list" ref={list} onScroll={measure}>
666
+ {states.map(({ p, s }) => (
667
+ <button type="button" class={`place-chip st-${s.kind}`} data-place={p.key} aria-pressed={shown === p.key && (!!watched || !onFollow || !building)} title={`${nice(p.key)}: ${s.say}. Click to ${verb}`} onClick={() => onWatch(p.key)}>
668
+ <span class="thumb">{p.image ? <img src={fileUrl(slug, p.image.file, p.image.v)} alt="" /> : <span class="none" />}<i class="state" /></span>
669
+ <span class="words"><b>{nice(p.key)}</b><small>{s.kind === 'working' ? <><span class="dot" />{s.say}</> : s.say}</small></span>
670
+ </button>
671
+ ))}
672
+ </div>
673
+ {edge.right && <button type="button" class="ghost scroll-arrow right" aria-label="More places" onClick={() => page(1)}>›</button>}
674
+ </div>
675
+ </nav>
676
+ );
677
+ }
678
+
679
+ const nice = (key: string) => key.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase());