@gtanczyk/diorama 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +152 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +248 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +31 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/models.ts +64 -0
  20. package/app/src/actions/motion.ts +119 -0
  21. package/app/src/actions/pictures.ts +49 -0
  22. package/app/src/actions/reactions.ts +36 -0
  23. package/app/src/actions/registry.ts +101 -0
  24. package/app/src/actions/share.ts +32 -0
  25. package/app/src/actions/sound.ts +73 -0
  26. package/app/src/actions/start.ts +154 -0
  27. package/app/src/actions/step-work.ts +150 -0
  28. package/app/src/actions/story.ts +101 -0
  29. package/app/src/actions/studio.ts +133 -0
  30. package/app/src/actions/style.ts +72 -0
  31. package/app/src/actions/video.ts +46 -0
  32. package/app/src/actions/view.ts +109 -0
  33. package/app/src/actions/weather.ts +38 -0
  34. package/app/src/actions/world.ts +289 -0
  35. package/app/src/commands/action.ts +39 -0
  36. package/app/src/commands/backdrop.ts +188 -0
  37. package/app/src/commands/browsers.ts +21 -0
  38. package/app/src/commands/build.ts +503 -0
  39. package/app/src/commands/cast.ts +142 -0
  40. package/app/src/commands/clip.ts +431 -0
  41. package/app/src/commands/derive.ts +167 -0
  42. package/app/src/commands/doctor.ts +74 -0
  43. package/app/src/commands/explore.ts +123 -0
  44. package/app/src/commands/export.ts +142 -0
  45. package/app/src/commands/extend.ts +75 -0
  46. package/app/src/commands/faces.ts +80 -0
  47. package/app/src/commands/icons.ts +69 -0
  48. package/app/src/commands/index.ts +36 -0
  49. package/app/src/commands/layout.ts +31 -0
  50. package/app/src/commands/masks.ts +117 -0
  51. package/app/src/commands/mcp.ts +52 -0
  52. package/app/src/commands/model.ts +284 -0
  53. package/app/src/commands/paint.ts +182 -0
  54. package/app/src/commands/plates.ts +97 -0
  55. package/app/src/commands/play.ts +192 -0
  56. package/app/src/commands/playthrough.ts +111 -0
  57. package/app/src/commands/publish.ts +78 -0
  58. package/app/src/commands/reactions.ts +40 -0
  59. package/app/src/commands/reel.ts +46 -0
  60. package/app/src/commands/setup.ts +85 -0
  61. package/app/src/commands/sound.ts +90 -0
  62. package/app/src/commands/sprites.ts +232 -0
  63. package/app/src/commands/story.ts +37 -0
  64. package/app/src/commands/studio.ts +65 -0
  65. package/app/src/commands/style.ts +87 -0
  66. package/app/src/commands/verify.ts +45 -0
  67. package/app/src/commands/voice-retry.ts +14 -0
  68. package/app/src/commands/voices.ts +145 -0
  69. package/app/src/commands/weather.ts +52 -0
  70. package/app/src/commands/world.ts +104 -0
  71. package/app/src/film/clip.ts +386 -0
  72. package/app/src/film/reel.ts +126 -0
  73. package/app/src/film/screencast.ts +123 -0
  74. package/app/src/image/mask.ts +29 -0
  75. package/app/src/image/overlay.ts +38 -0
  76. package/app/src/image/raster.ts +224 -0
  77. package/app/src/main.ts +28 -0
  78. package/app/src/meta/choice.ts +27 -0
  79. package/app/src/meta/http.ts +99 -0
  80. package/app/src/meta/memo.ts +91 -0
  81. package/app/src/meta/muse.ts +70 -0
  82. package/app/src/meta/openrouter.ts +93 -0
  83. package/app/src/meta/prices.ts +25 -0
  84. package/app/src/meta/sam.ts +83 -0
  85. package/app/src/meta/schema.ts +63 -0
  86. package/app/src/meta/spark.ts +220 -0
  87. package/app/src/meta/step.ts +14 -0
  88. package/app/src/meta/voice-file.ts +78 -0
  89. package/app/src/meta/voice.ts +103 -0
  90. package/app/src/model/animation.ts +38 -0
  91. package/app/src/model/blender/relax.py +284 -0
  92. package/app/src/model/blender.ts +115 -0
  93. package/app/src/model/build.ts +50 -0
  94. package/app/src/model/face.ts +21 -0
  95. package/app/src/model/fit.ts +181 -0
  96. package/app/src/model/glb.ts +241 -0
  97. package/app/src/model/plan.ts +151 -0
  98. package/app/src/model/quality.ts +48 -0
  99. package/app/src/model/space.ts +168 -0
  100. package/app/src/model/sprites.ts +293 -0
  101. package/app/src/model/views.ts +163 -0
  102. package/app/src/play/creature.ts +43 -0
  103. package/app/src/play/depth-occlusion.ts +150 -0
  104. package/app/src/play/exit-outline.ts +48 -0
  105. package/app/src/play/face3d.ts +34 -0
  106. package/app/src/play/focus.ts +78 -0
  107. package/app/src/play/geometry.ts +272 -0
  108. package/app/src/play/gestures.ts +23 -0
  109. package/app/src/play/glow.ts +187 -0
  110. package/app/src/play/lipsync.ts +69 -0
  111. package/app/src/play/living.ts +83 -0
  112. package/app/src/play/outline-path.ts +130 -0
  113. package/app/src/play/parallax.ts +200 -0
  114. package/app/src/play/path.ts +262 -0
  115. package/app/src/play/preview.ts +71 -0
  116. package/app/src/play/reactions.ts +92 -0
  117. package/app/src/play/rig.ts +172 -0
  118. package/app/src/play/shadow.ts +253 -0
  119. package/app/src/play/sound.ts +51 -0
  120. package/app/src/play/weather.ts +58 -0
  121. package/app/src/qa/explore.ts +136 -0
  122. package/app/src/qa/probe.ts +139 -0
  123. package/app/src/qa/route.ts +118 -0
  124. package/app/src/registry.ts +23 -0
  125. package/app/src/scene/animation-source.ts +36 -0
  126. package/app/src/scene/assets.ts +37 -0
  127. package/app/src/scene/autofix.ts +133 -0
  128. package/app/src/scene/backdrop.ts +326 -0
  129. package/app/src/scene/blockin.ts +189 -0
  130. package/app/src/scene/brief.ts +74 -0
  131. package/app/src/scene/carry.ts +177 -0
  132. package/app/src/scene/cast-fixes.ts +31 -0
  133. package/app/src/scene/cast.ts +192 -0
  134. package/app/src/scene/corrections.ts +164 -0
  135. package/app/src/scene/critic.ts +169 -0
  136. package/app/src/scene/depthmap.ts +149 -0
  137. package/app/src/scene/derive.ts +475 -0
  138. package/app/src/scene/diagnose.ts +132 -0
  139. package/app/src/scene/face-calibration.ts +65 -0
  140. package/app/src/scene/face-registration.ts +21 -0
  141. package/app/src/scene/face-work.ts +71 -0
  142. package/app/src/scene/faces-data.ts +48 -0
  143. package/app/src/scene/faces-settings.ts +20 -0
  144. package/app/src/scene/faces.ts +435 -0
  145. package/app/src/scene/glows.ts +153 -0
  146. package/app/src/scene/held.ts +55 -0
  147. package/app/src/scene/icons.ts +185 -0
  148. package/app/src/scene/layers.ts +822 -0
  149. package/app/src/scene/layout.ts +216 -0
  150. package/app/src/scene/locate.ts +118 -0
  151. package/app/src/scene/maskcheck.ts +43 -0
  152. package/app/src/scene/masks.ts +392 -0
  153. package/app/src/scene/occluders.ts +123 -0
  154. package/app/src/scene/occlusion-check.ts +138 -0
  155. package/app/src/scene/paint-layout.ts +135 -0
  156. package/app/src/scene/paint.ts +81 -0
  157. package/app/src/scene/photo.ts +123 -0
  158. package/app/src/scene/plates.ts +256 -0
  159. package/app/src/scene/playbook.ts +121 -0
  160. package/app/src/scene/preview.ts +23 -0
  161. package/app/src/scene/reactions.ts +118 -0
  162. package/app/src/scene/restyle.ts +53 -0
  163. package/app/src/scene/rig-check.ts +68 -0
  164. package/app/src/scene/rig.ts +375 -0
  165. package/app/src/scene/scale.ts +148 -0
  166. package/app/src/scene/shadows.ts +207 -0
  167. package/app/src/scene/stairs.ts +183 -0
  168. package/app/src/scene/status.ts +32 -0
  169. package/app/src/scene/verify.ts +156 -0
  170. package/app/src/scene/walk.ts +326 -0
  171. package/app/src/scene/warp.ts +417 -0
  172. package/app/src/scene/weather.ts +118 -0
  173. package/app/src/scene/words.ts +65 -0
  174. package/app/src/secrets.ts +219 -0
  175. package/app/src/setup/checks.ts +110 -0
  176. package/app/src/sound/cost.ts +74 -0
  177. package/app/src/sound/data.ts +89 -0
  178. package/app/src/sound/fixes.ts +23 -0
  179. package/app/src/sound/issues.ts +21 -0
  180. package/app/src/sound/language.ts +11 -0
  181. package/app/src/sound/lipsync-make.ts +98 -0
  182. package/app/src/sound/lipsync.ts +24 -0
  183. package/app/src/sound/make.ts +229 -0
  184. package/app/src/sound/musicgen.py +20 -0
  185. package/app/src/sound/pcm.ts +280 -0
  186. package/app/src/sound/plan.ts +154 -0
  187. package/app/src/sound/retry.ts +43 -0
  188. package/app/src/sound/settings.ts +28 -0
  189. package/app/src/sound/sources.ts +81 -0
  190. package/app/src/sound/spoken.ts +70 -0
  191. package/app/src/sound/voices.ts +579 -0
  192. package/app/src/speech/transcribe.ts +38 -0
  193. package/app/src/speech/types.ts +18 -0
  194. package/app/src/story/check.ts +230 -0
  195. package/app/src/story/design.ts +108 -0
  196. package/app/src/story/engine.ts +233 -0
  197. package/app/src/story/fixes.ts +54 -0
  198. package/app/src/story/npc-guidance.ts +7 -0
  199. package/app/src/story/phrases.ts +4 -0
  200. package/app/src/story/schema.ts +92 -0
  201. package/app/src/studio/agent.ts +554 -0
  202. package/app/src/studio/discovery.ts +28 -0
  203. package/app/src/studio/events.ts +48 -0
  204. package/app/src/studio/history.ts +124 -0
  205. package/app/src/studio/jobs.ts +291 -0
  206. package/app/src/studio/locks.ts +33 -0
  207. package/app/src/studio/mcp.ts +86 -0
  208. package/app/src/studio/operation.ts +18 -0
  209. package/app/src/studio/repair.ts +31 -0
  210. package/app/src/studio/server.ts +421 -0
  211. package/app/src/studio/trouble.ts +48 -0
  212. package/app/src/world/design.ts +72 -0
  213. package/app/src/world/drawings.ts +198 -0
  214. package/app/src/world/extend-stage.ts +109 -0
  215. package/app/src/world/extend.ts +123 -0
  216. package/app/src/world/extension-progress.ts +10 -0
  217. package/app/src/world/files.ts +62 -0
  218. package/app/src/world/idea-review.ts +32 -0
  219. package/app/src/world/photo-limits.ts +2 -0
  220. package/app/src/world/pool.ts +41 -0
  221. package/app/src/world/schedule.ts +64 -0
  222. package/app/src/world/schema.ts +74 -0
  223. package/app/src/world/sketch.ts +81 -0
  224. package/app/src/world/style-plan.ts +90 -0
  225. package/app/src/world/style-reference.ts +16 -0
  226. package/app/src/world/styled.ts +51 -0
  227. package/app/src/world/told.ts +105 -0
  228. package/app/src/world/video-limits.ts +3 -0
  229. package/app/src/world/video.ts +98 -0
  230. package/app/studio/activity.tsx +124 -0
  231. package/app/studio/agent.tsx +384 -0
  232. package/app/studio/api.ts +111 -0
  233. package/app/studio/atelier.tsx +234 -0
  234. package/app/studio/audio-missing.tsx +25 -0
  235. package/app/studio/audio-setup.tsx +33 -0
  236. package/app/studio/blender-setup.tsx +30 -0
  237. package/app/studio/build.tsx +677 -0
  238. package/app/studio/camera.tsx +59 -0
  239. package/app/studio/cast-work.tsx +207 -0
  240. package/app/studio/cast.tsx +178 -0
  241. package/app/studio/concept-edit.tsx +47 -0
  242. package/app/studio/creator-idea.tsx +46 -0
  243. package/app/studio/custom-look.tsx +36 -0
  244. package/app/studio/faces-setup.tsx +28 -0
  245. package/app/studio/favicon.svg +11 -0
  246. package/app/studio/figure.tsx +314 -0
  247. package/app/studio/fonts/OFL.txt +93 -0
  248. package/app/studio/fonts/fonts.css +7 -0
  249. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  250. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  251. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  252. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  253. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  254. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  255. package/app/studio/icons.tsx +42 -0
  256. package/app/studio/idea-card.tsx +42 -0
  257. package/app/studio/idea-review.tsx +181 -0
  258. package/app/studio/idea-storyboard.tsx +134 -0
  259. package/app/studio/index.html +15 -0
  260. package/app/studio/job-words.ts +33 -0
  261. package/app/studio/lightbox.tsx +49 -0
  262. package/app/studio/main.tsx +1328 -0
  263. package/app/studio/nav.ts +14 -0
  264. package/app/studio/place.tsx +264 -0
  265. package/app/studio/review-sketch.tsx +47 -0
  266. package/app/studio/say-it.tsx +49 -0
  267. package/app/studio/step-work.tsx +134 -0
  268. package/app/studio/story.tsx +394 -0
  269. package/app/studio/style-menu.tsx +81 -0
  270. package/app/studio/style.css +1390 -0
  271. package/app/studio/sure.tsx +54 -0
  272. package/app/studio/tip.tsx +74 -0
  273. package/app/studio/tsconfig.json +5 -0
  274. package/app/studio/video.tsx +73 -0
  275. package/app/studio/world.tsx +188 -0
  276. package/app/tsconfig.json +13 -0
  277. package/app/web/audio.ts +315 -0
  278. package/app/web/face.ts +131 -0
  279. package/app/web/face3d.ts +35 -0
  280. package/app/web/glows.ts +387 -0
  281. package/app/web/hero3d.ts +728 -0
  282. package/app/web/index.html +118 -0
  283. package/app/web/living.ts +404 -0
  284. package/app/web/main.ts +3751 -0
  285. package/app/web/pad.ts +174 -0
  286. package/app/web/reactions.ts +79 -0
  287. package/app/web/sheet.ts +36 -0
  288. package/app/web/synth.ts +140 -0
  289. package/app/web/tsconfig.json +5 -0
  290. package/app/web/ui.ts +263 -0
  291. package/app/web/weather.ts +306 -0
  292. package/app/web/words.ts +12 -0
  293. package/docs/characters.md +58 -0
  294. package/docs/npc-motion.md +69 -0
  295. package/docs/scenes.md +75 -0
  296. package/docs/setup.md +60 -0
  297. package/docs/sound.md +23 -0
  298. package/docs/studio.md +62 -0
  299. package/package.json +70 -4
@@ -0,0 +1,1328 @@
1
+ // Diorama Studio: Start (worlds and a new idea), Setup (what this machine needs) and the Workshop (a world
2
+ // building live). Everything here goes through actions, the same ones `diorama action` and the agent use.
3
+ import { render, type ComponentChildren } from 'preact';
4
+ import { useEffect, useRef, useState } from 'preact/hooks';
5
+ import type { ChatNotice, SessionTurn } from 'genaicode/react';
6
+ import { agentDefault, askAgent, call, type AgentDefaults, fileUrl, follow, listActions, openSession, token, type StudioEvent } from './api.ts';
7
+ import { AgentPanel } from './agent.tsx';
8
+ import { CastView } from './cast.tsx';
9
+ import { AudioSetup } from './audio-setup.tsx';
10
+ import { BlenderSetup } from './blender-setup.tsx';
11
+ import { FacesSetup } from './faces-setup.tsx';
12
+ import { VideoInput, type VideoResult } from './video.tsx';
13
+ import { IdeaReview, type ReviewInput } from './idea-review.tsx';
14
+ import { CreatorIdea, useCreatorBrief } from './creator-idea.tsx';
15
+ import type { CreativeChoices } from '../src/world/idea-review.ts';
16
+ import { AudioMissing } from './audio-missing.tsx';
17
+ import { base64, Camera, shrink } from './camera.tsx';
18
+ import { StoryView, type StoryProgress } from './story.tsx';
19
+ import { StyleView } from './style-menu.tsx';
20
+ import { askSure, Sure } from './sure.tsx';
21
+ import { PlaceEditor } from './place.tsx';
22
+ import { BuildPlaces, callBy, PHASES, extensionDetail, type PlaceRuns, MODEL_NAME, named, Pipeline, standIn, viaName, Stage, STEP_SHOWS, working, type LayerName, type Live, type StagePlace } from './build.tsx';
23
+ import type { ExtensionProgress } from '../src/world/extension-progress.ts';
24
+ import { Bell, Logo, Rail, forgetWorld, useAtelierFeed, worlds as allWorlds } from './atelier.tsx';
25
+ import { CAST_STEPS, castFile, castLine, CastWorkStage, lineStep, castTouched, type CastWork } from './cast-work.tsx';
26
+ import { StepWorkStage, WORK_STEPS } from './step-work.tsx';
27
+ import { SayIt } from './say-it.tsx';
28
+ import { Card, CastCard, CastStage, nice, PlacesCard, PlaceStage, WorldStrip, type StageView } from './world.tsx';
29
+ import { Tips } from './tip.tsx';
30
+ import { Lightbox } from './lightbox.tsx';
31
+ import { Activity, type ActivityJob, type ActivityLine } from './activity.tsx';
32
+ import { followAgent, go, movedFrom, route } from './nav.ts';
33
+ import { MAX_WORLD_PHOTOS } from '../src/world/photo-limits.ts';
34
+ import { Icon } from './icons.tsx';
35
+
36
+ type Check = { id: string; label: string; state: 'ok' | 'missing' | 'optional' | 'fail'; detail: string; fix?: string[] };
37
+ type Summary = { slug: string; title: string; pitch: string | null; issue: string | null; places: number; playable: number; verified: number; steps: string[]; cover: { file: string; v: number | null } | null; workshop?: boolean };
38
+ type Finding = { code: string; key?: string; detail: string };
39
+ type PlaceDetail = {
40
+ key: string;
41
+ shows: string;
42
+ things: Array<{ key: string; role: string }>;
43
+ stages: Record<string, number | null>;
44
+ image: { file: string; v: number | null } | null;
45
+ layers?: StagePlace['layers'];
46
+ verdict: { ok: boolean; errors: Array<Finding & { play?: { look: string; tools: string[]; last?: string } }>; flags: Finding[] } | null;
47
+ trouble?: { why: string; from: string | null } | null;
48
+ };
49
+ type Job = { id: string; label: string; args: string[]; unplayable?: Array<{ place: string; errors: string[] }>; state: string; by: string; step?: string; extension?: ExtensionProgress; error?: string; startedAt?: string; endedAt?: string; tail: string[] };
50
+ type Note = { id: string; at: string; by: string; place?: string; point?: [number, number]; text: string; state: 'open' | 'done'; reply?: string };
51
+ type Spend = { usd: number; priced: boolean; calls: Record<string, number> };
52
+ type Budget = { total: Spend; today: Spend; limitUsd: number | null; creditsLeft: number | null; sparkTier: string; sparkVia: string | null; imageVia: string | null; elsewhere: { calls: number; usd: number }; over: boolean; prices: Record<string, number> };
53
+ type Status = Summary & { world: { start: string } | null; placeDetail: PlaceDetail[]; build: Record<string, { seconds: number }>; typical?: Record<string, number>; calls: Record<string, number>; callsVia?: Record<string, number>; jobs: Job[]; audioMisses?: { sounds: Array<{ id: string; reason: string }>; lines: Array<{ speaker: string; text: string; reason: string }> } };
54
+
55
+ let steps: string[] = [];
56
+ let quiet = new Set<string>();
57
+
58
+ function Mark({ state }: { state: Check['state'] | 'run' | 'todo' }) {
59
+ const glyph = { ok: '✓', missing: '!', optional: '·', fail: '✕', run: '◌', todo: '' }[state];
60
+ return <span class={`mark ${state}`} aria-label={state}>{glyph}</span>;
61
+ }
62
+
63
+ const clock = (ms: number) => {
64
+ const t = Math.max(0, Math.round(ms / 1000));
65
+ return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
66
+ };
67
+
68
+ /** What a job changed: the places it touched, else the first files. */
69
+ function touched(s: { places?: string[]; sample?: string[] }): string {
70
+ return s.places?.length ? s.places.join(', ') : (s.sample ?? []).slice(0, 2).map((f) => f.split('/').at(-1)).join(', ');
71
+ }
72
+
73
+ const ago = (at: string) => {
74
+ const m = Math.round((Date.now() - Date.parse(at)) / 60_000);
75
+ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 24 * 60 ? `${Math.round(m / 60)} h ago` : new Date(at).toLocaleDateString();
76
+ };
77
+
78
+ type Snap = { id: string; at: string; label: string; by: string; files: number; added?: number; changed?: number; removed?: number; sample?: string[]; places?: string[] };
79
+
80
+ /**
81
+ * History: every job the world went through, newest first, with who ran it and what it changed; any of them can
82
+ * be gone back on, to the world as it was before it. The back step is itself kept, so Redo or a later pick undoes it.
83
+ */
84
+ /** The world's history in its own drawer: one row per job, newest first, who ran it, what it changed, and going back to before it. */
85
+ function HistoryView({ slug, version, onError, onDone }: { slug: string; version: number; onError: (m: string) => void; onDone: () => void }) {
86
+ const [snaps, setSnaps] = useState<Snap[] | null>(null);
87
+ const [sure, setSure] = useState<string | null>(null);
88
+ useEffect(() => void call<Snap[]>('history_list', { slug, limit: 100 }).then(setSnaps).catch((e) => onError(e.message)), [slug, version]);
89
+ const back = (id: string) => call('history_undo', { slug, id }).then(() => (setSure(null), onDone())).catch((e) => onError(e.message));
90
+ if (!snaps) return <p class="detail">Reading the history…</p>;
91
+ // The job that made the world has no "before" to go back to: history starts with the job after it.
92
+ if (!snaps.length) return <p class="note">Nothing to go back to yet. The build that made this world has no "before"; from the next job on (yours or the agent's), each one can be taken back here.</p>;
93
+ return (
94
+ <ol class="history-list">
95
+ {snaps.map((s) => (
96
+ <li key={s.id}>
97
+ <div class="row">
98
+ <strong>{s.label.replace(/^before /, '')}</strong>
99
+ <span class="grow" />
100
+ <span class="detail">{s.by === 'agent' ? 'the agent' : 'you'} · {ago(s.at)}</span>
101
+ </div>
102
+ {s.changed !== undefined && <p class="detail">{(s.changed ?? 0) + (s.added ?? 0) + (s.removed ?? 0) === 0 ? 'changed nothing' : `changed ${touched(s)} · ${(s.changed ?? 0) + (s.added ?? 0)} file${(s.changed ?? 0) + (s.added ?? 0) === 1 ? '' : 's'}${s.removed ? `, removed ${s.removed}` : ''}`}</p>}
103
+ {sure === s.id
104
+ ? <div class="row"><span class="detail">Everything after it goes too.</span><span class="grow" /><button type="button" class="primary small" title="Put the world back as it was before this job. Redo takes it back. Free" onClick={() => back(s.id)}>Go back</button><button type="button" class="ghost small" onClick={() => setSure(null)}>Cancel</button></div>
105
+ : <div class="row"><span class="grow" /><button type="button" class="ghost small" title="Asks once more before going back. Everything after this job goes too" onClick={() => setSure(s.id)}>Back to before this</button></div>}
106
+ </li>
107
+ ))}
108
+ </ol>
109
+ );
110
+ }
111
+
112
+ /** Sharing: a zip to download, or the public link. The last export or publish job says where it went. */
113
+ function Share({ slug, jobs, onError }: { slug: string; jobs: Job[]; onError: (m: string) => void }) {
114
+ const last = (args0: string) => jobs.findLast((j) => j.args[0] === args0 && j.state === 'done');
115
+ const exported = last('export');
116
+ const link = last('publish')?.tail.map((l) => /play: (https?:\S+)/.exec(l)?.[1]).find(Boolean);
117
+ const firstTime = last('publish')?.tail.find((l) => l.includes('first publish:'))?.trim();
118
+ const publish = () => {
119
+ void askSure({ title: 'Put it on the web?', body: `${slug} gets a public link: anyone who has it can play the game.`, go: 'Publish' }).then((yes) => yes && call('world_publish', { slug }).catch((e) => onError(e.message)));
120
+ };
121
+ return (
122
+ <div class="menu-group">
123
+ <p class="menu-head">Share this game</p>
124
+ <button type="button" class="ghost small" title="Pack a playable copy to download, for itch.io or e-mail. Free, no model calls" onClick={() => call('world_export', { slug }).catch((e) => onError(e.message))}>Make a zip</button>
125
+ {exported && <a class="detail" href={`/exports/${slug}.zip`} download>Download {slug}.zip</a>}
126
+ <p class="detail">A zip plays offline and goes on itch.io as an HTML game.</p>
127
+ <button type="button" class="ghost small" title="Put the game on GitHub Pages: anyone with the link can play. Asks first. Free, no model calls" onClick={publish}>Publish a link</button>
128
+ {link && <a class="detail" href={link} target="_blank" rel="noopener">{link}</a>}
129
+ {firstTime && <p class="detail">{firstTime}</p>}
130
+ <p class="detail">Puts it on this repository's GitHub Pages, public.</p>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ /** What is seldom needed waits behind one button: following the agent, Setup, and whatever the page adds. */
136
+ function Menu({ children }: { children?: ComponentChildren }) {
137
+ const [open, setOpen] = useState(false);
138
+ useEffect(() => {
139
+ if (!open) return;
140
+ const close = (e: Event) => !(e.target as HTMLElement).closest?.('.menu-wrap, .share-menu') && setOpen(false);
141
+ // Esc closes the menu only, not also the view under it.
142
+ const esc = (e: KeyboardEvent) => e.key === 'Escape' && (e.stopImmediatePropagation(), setOpen(false));
143
+ addEventListener('pointerdown', close);
144
+ addEventListener('keydown', esc, true);
145
+ return () => (removeEventListener('pointerdown', close), removeEventListener('keydown', esc, true));
146
+ }, [open]);
147
+ return (
148
+ <div class="share menu-wrap">
149
+ <button type="button" class="ghost menu-button" aria-label="More" title="More: follow the agent, history, style, Setup" aria-expanded={open} onClick={() => setOpen(!open)}>☰</button>
150
+ {open && (
151
+ <div class="share-menu top-menu" role="menu" onClick={(e) => (e.target as HTMLElement).closest('a, .mi.go') && setOpen(false)}>
152
+ {children && <p class="menu-head">This diorama</p>}
153
+ {children}
154
+ {children && <hr />}
155
+ <p class="menu-head">Studio</p>
156
+ <label class="mi switch" title="When the agent opens a place, the cast or the game, the stage goes there too"><span>Follow the agent</span><input type="checkbox" role="switch" checked={followAgent.value} onChange={(e) => (followAgent.value = e.currentTarget.checked)} /></label>
157
+ <a href="/setup" class="mi" title="Keys, tools and the budget on this computer" onClick={(e) => (e.preventDefault(), go('/setup'))}>Setup</a>
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ /**
165
+ * What the agent asked at the end of its turn: its ask_person question, or a plain question it ended its answer
166
+ * with, which gets a yes and a no in the language it was asked in.
167
+ */
168
+ function asked(turn: SessionTurn): { question: string; options: string[]; recommended?: number } | null {
169
+ const tool = turn.entries.findLast((e) => e.kind === 'tool' && e.name.endsWith('ask_person'));
170
+ if (tool?.kind === 'tool' && !tool.isError) {
171
+ const q = tool.input as { question?: string; options?: string[]; recommended?: number } | undefined;
172
+ if (q?.question && q.options?.length) return { question: q.question, options: q.options, recommended: q.recommended };
173
+ }
174
+ const text = turn.entries.findLast((e) => e.kind === 'text');
175
+ if (text?.kind !== 'text' || !/\?\s*$/.test(text.text)) return null;
176
+ // Only a yes-or-no question gets yes and no: "What should I do?" or "the bridge or the hedgehog?" is answered in words.
177
+ const last = (text.text.trim().split(/(?<=[.!?:])\s+|\n+/).at(-1) ?? '').replace(/^[\s*_>"„-]+/, '');
178
+ if (/^(co|jak|jaki|jaka|jakie|jakich|kt[óo]r\w*|gdzie|kiedy|ile|dlaczego|czemu|kto|kogo|komu|w jaki|od czego|na czym|what|which|how|where|when|who|whom|why|whose)\b/i.test(last)) return null;
179
+ if (/\S\s+(czy|or|albo|lub)\s+\S[^?]*\?\s*$/i.test(last)) return null;
180
+ const pl = /[ąćęłńóśźż]|\b(czy|się|mogę|chcesz)\b/i.test(text.text);
181
+ return { question: '', options: pl ? ['Tak', 'Nie'] : ['Yes', 'No'], recommended: 0 };
182
+ }
183
+
184
+ /** Build again runs every step once more, so it asks first; going on from where a build stopped does not. */
185
+ function Rebuild({ slug, again, onError }: { slug: string; again: boolean; onError: (m: string) => void }) {
186
+ const run = () => call('world_build', { slug }).catch((e) => onError(e.message));
187
+ if (!again) return <button type="button" class="mi go" title="Go on from where the build stopped. Answers already asked are reused for free" onClick={run}>Continue the build</button>;
188
+ return <button type="button" class="mi danger go" title="Run every step of the build again. Asks first" onClick={() => void askSure({ title: 'Build everything again?', body: 'Every step runs again over the whole world. What was asked before is reused for free; anything new costs model calls. History takes it back.', go: 'Build again', danger: true }).then((yes) => yes && run())}>Build everything again…</button>;
189
+ }
190
+
191
+ /** The bell and the menu, top right of a page with no stage of its own. */
192
+ function Corner() {
193
+ return <div class="corner"><Bell /><Menu /></div>;
194
+ }
195
+
196
+ /** Share as one button: the zip and the public link behind it. */
197
+ function ShareButton({ slug, jobs, onError }: { slug: string; jobs: Job[]; onError: (m: string) => void }) {
198
+ const [open, setOpen] = useState(false);
199
+ useEffect(() => {
200
+ if (!open) return;
201
+ const close = (e: Event) => !(e.target as HTMLElement).closest?.('.share-wrap') && setOpen(false);
202
+ addEventListener('pointerdown', close);
203
+ return () => removeEventListener('pointerdown', close);
204
+ }, [open]);
205
+ return (
206
+ <div class="share share-wrap">
207
+ <button type="button" class="ghost" aria-expanded={open} title="A zip for itch.io, or a link anyone can play" onClick={() => setOpen(!open)}>Share</button>
208
+ {open && <div class="share-menu" role="menu"><Share slug={slug} jobs={jobs} onError={onError} /></div>}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ /** Which model does the running step, for the build card. */
214
+ /** Each step's phase on the build bar. */
215
+ const STEP_PHASE: Record<string, string> = Object.fromEntries(PHASES.flatMap((p) => p.steps.map((s) => [s, p.key])));
216
+ const MODEL_FOR: Record<string, string> = { world: 'Muse Spark', story: 'Muse Spark', layout: 'Muse Spark', paint: 'Muse Image', masks: 'SAM 3.1', plates: 'Muse Image', icons: 'Muse Image', derive: 'Depth', backdrop: 'Muse Image', reactions: 'Muse Spark', cast: 'Muse Image', model: '3D', sprites: 'Muse Image', sound: 'ElevenLabs', voices: 'ElevenLabs', faces: 'Muse Image', verify: 'play test' };
217
+
218
+ function Copy({ text }: { text: string }) {
219
+ const [done, setDone] = useState(false);
220
+ return <button type="button" class="ghost small" title="Copy these commands to paste in a terminal" onClick={() => navigator.clipboard?.writeText(text).then(() => (setDone(true), setTimeout(() => setDone(false), 1200)))}>{done ? 'Copied' : 'Copy'}</button>;
221
+ }
222
+
223
+ function Setup() {
224
+ const [checks, setChecks] = useState<Check[] | null>(null);
225
+ const [busy, setBusy] = useState('');
226
+ const [error, setError] = useState('');
227
+ const run = async (name: 'setup_status' | 'setup_check') => {
228
+ setBusy(name);
229
+ setError('');
230
+ try {
231
+ setChecks(await call<Check[]>(name));
232
+ } catch (e) {
233
+ setError((e as Error).message);
234
+ }
235
+ setBusy('');
236
+ };
237
+ useEffect(() => void run('setup_status'), []);
238
+ const ok = checks?.filter((c) => c.state === 'ok').length ?? 0;
239
+ const blocking = checks?.filter((c) => c.state === 'missing' || c.state === 'fail') ?? [];
240
+ const from = history.state?.setupFrom as unknown;
241
+ const back = typeof from === 'string' && (from === '/' || /^\/w\/[a-z][a-z0-9-]*$/.test(from)) ? from : null;
242
+ return (
243
+ <>
244
+ <Corner />
245
+ {/* What to do on the left, the checks on the right, each scrolling on its own: the page itself never does. */}
246
+ <main class="setup">
247
+ <div class="side">
248
+ <div class="setup-heading">
249
+ <a href={back ?? '/'} class="setup-back" aria-label={back?.startsWith('/w/') ? 'Back to world' : 'Back to Studio'} title={back?.startsWith('/w/') ? 'Back to world' : 'Back to Studio'} onClick={(e) => { e.preventDefault(); if (back) history.back(); else go('/'); }}>←</a>
250
+ <h1>Setup</h1>
251
+ </div>
252
+ <p class="lead">{!checks ? 'Checking this machine…' : blocking.length ? `${blocking.map((c) => c.label).join(' and ')} still ${blocking.length > 1 ? 'need' : 'needs'} you. Everything else is ready or optional.` : `Everything diorama needs is here.${ok < checks.length ? ' The unticked ones are optional.' : ''}`}</p>
253
+ <p class="note">Keys are typed only in your terminal with <code>diorama setup</code>, hidden, and kept in your system keychain. This page never sees them.</p>
254
+ {error && <p class="error">{error}</p>}
255
+ <div class="row">
256
+ <button type="button" class="primary" disabled={!!busy} title="Look at this computer again: keys, tools, the agent. Free" onClick={() => run('setup_status')}>{busy === 'setup_status' ? 'Checking…' : 'Check again'}</button>
257
+ <button type="button" class="ghost" disabled={!!busy || checks?.find((c) => c.id === 'META_API_KEY')?.state === 'missing'} title="Send one tiny request to Meta to see the key works. 1 small Meta call" onClick={() => run('setup_check')}>{busy === 'setup_check' ? 'Calling Meta…' : 'Test the Meta key live'}</button>
258
+ </div>
259
+ <DefaultAgent />
260
+ <AudioSetup />
261
+ <BlenderSetup />
262
+ <FacesSetup />
263
+ <BudgetBox />
264
+ </div>
265
+ <ol class="checks">
266
+ {checks?.map((c) => (
267
+ <li class={c.state}>
268
+ <Mark state={c.state} />
269
+ <div class="body">
270
+ <strong>{c.label}</strong>
271
+ <span class="detail">{c.detail}</span>
272
+ {c.fix && c.state !== 'ok' && (
273
+ <div class="fix">
274
+ <pre>{c.fix.join('\n')}</pre>
275
+ <Copy text={c.fix.join('\n')} />
276
+ </div>
277
+ )}
278
+ </div>
279
+ </li>
280
+ ))}
281
+ </ol>
282
+ </main>
283
+ </>
284
+ );
285
+ }
286
+
287
+ const MODEL_NAMES: Record<string, string> = { spark: 'Muse Spark', muse: 'Muse Image', sam: 'SAM 3.1', sound: 'ElevenLabs sound', voice: 'Voices', music: 'Music', space: 'HF Spaces' };
288
+
289
+ type Pick = { id: string; label: string; ready: boolean };
290
+ type Models = { spark: string; image: string; env: { spark: string | null; image: string | null }; options: { spark: Pick[]; image: Pick[] } };
291
+
292
+ /** Setup's model pick into the labels everywhere (build bar, stage, feed). */
293
+ function showModels(m: Models) {
294
+ standIn.value = { spark: m.spark === 'meta' ? null : viaName(m.spark), image: m.image === 'meta' ? null : viaName(m.image) };
295
+ return m;
296
+ }
297
+
298
+ /**
299
+ * Who works on a world: the agent a new world's conversation starts with (a world keeps the one it last talked
300
+ * with), the model for Spark's asks and the one that paints. One row, so Setup still fits the screen.
301
+ */
302
+ function DefaultAgent() {
303
+ const [d, setD] = useState<AgentDefaults | null>(null);
304
+ const [m, setM] = useState<Models | null>(null);
305
+ const [error, setError] = useState('');
306
+ useEffect(() => {
307
+ void agentDefault().then(setD).catch((e) => setError(e.message));
308
+ void call<Models>('models_get').then((x) => setM(showModels(x))).catch((e) => setError(e.message));
309
+ }, []);
310
+ if (!d) return error ? <p class="error">{error}</p> : null;
311
+ const pick = d.agents.find((a) => a.default)?.name;
312
+ const choose = (kind: 'spark' | 'image', id: string) => void call<Models>('models_set', { [kind]: id }).then((x) => setM(showModels(x))).catch((x) => setError(x.message));
313
+ const select = (kind: 'spark' | 'image', label: string, title: string) => m && (
314
+ <label title={m.env[kind] ? `Set by DIORAMA_${kind === 'spark' ? 'SPARK' : 'IMAGE'}_VIA=${m.env[kind]} where the Studio was started` : title}>{label}
315
+ <select value={m[kind]} disabled={!!m.env[kind]} onChange={(e) => choose(kind, e.currentTarget.value)}>
316
+ {!m.options[kind].some((o) => o.id === m[kind]) && <option value={m[kind]}>{m[kind]}</option>}
317
+ {m.options[kind].map((o) => <option value={o.id} disabled={!o.ready}>{o.label}{o.ready ? '' : ' (no key)'}</option>)}
318
+ </select>
319
+ </label>
320
+ );
321
+ return (
322
+ <div class="budget default-agent">
323
+ <div class="prices">
324
+ <label title={d.env ? `Set by DIORAMA_AGENT=${d.env} where the Studio was started` : 'The agent a new world talks with. A world keeps the one it last talked with; Hand over… in a world changes it there'}>Default agent
325
+ <select value={pick} disabled={!!d.env} onChange={(e) => void agentDefault(e.currentTarget.value).then(setD).catch((x) => setError(x.message))}>
326
+ {d.agents.map((a) => <option value={a.name} disabled={!a.installed}>{a.label}{a.installed ? '' : ' (not installed)'}</option>)}
327
+ </select>
328
+ </label>
329
+ {select('spark', 'Thinking (Spark)', 'Who plans, locates and judges: Muse Spark, or Claude. Answers are kept per model, so switching back costs nothing')}
330
+ {select('image', 'Painting', 'Who paints places, cast and icons: Muse Image, or a Gemini image model. SAM 3.1 always cuts. The hackathon demo stays on Muse')}
331
+ </div>
332
+ {error && <span class="error">{error}</span>}
333
+ </div>
334
+ );
335
+ }
336
+
337
+ /** What the models cost so far, and the prices and limit to count it by. */
338
+ function BudgetBox() {
339
+ const [b, setB] = useState<Budget | null>(null);
340
+ const [prices, setPrices] = useState<Record<string, string>>({});
341
+ const [limit, setLimit] = useState('');
342
+ const [credits, setCredits] = useState('');
343
+ const [saved, setSaved] = useState('');
344
+ const load = () => call<Budget>('budget').then((x) => {
345
+ setB(x);
346
+ setPrices(Object.fromEntries(Object.entries(x.prices).map(([k, v]) => [k, String(v)])));
347
+ setLimit(x.limitUsd === null ? '' : String(x.limitUsd));
348
+ }).catch(() => {});
349
+ useEffect(() => void load(), []);
350
+ const save = async (e: Event) => {
351
+ e.preventDefault();
352
+ const p = Object.fromEntries(Object.entries(prices).filter(([, v]) => v.trim() !== '' && !Number.isNaN(Number(v))).map(([k, v]) => [k, Number(v)]));
353
+ await call('budget_set', { prices: p, ...(limit.trim() && { limitUsd: Number(limit) }), ...(credits.trim() && { creditsUsd: Number(credits) }) });
354
+ setCredits('');
355
+ setSaved('Saved');
356
+ setTimeout(() => setSaved(''), 1500);
357
+ void load();
358
+ };
359
+ if (!b) return null;
360
+ // Meta models are priced from each call (tokens, images); only the others take a price per call here.
361
+ const models = Object.keys(MODEL_NAMES).filter((m) => !['spark', 'muse', 'sam'].includes(m) && (b.total.calls[m] || m in b.prices));
362
+ return (
363
+ <form class="budget" onSubmit={save}>
364
+ <h2>Budget</h2>
365
+ <p class="lead">{b.total.priced ? `$${b.total.usd.toFixed(2)} spent` : `$${b.total.usd.toFixed(2)} on Meta, ${Object.values(b.total.calls).reduce((x, y) => x + y, 0)} model calls`}{b.limitUsd !== null ? ` of $${b.limitUsd}` : ''}, {b.today.priced ? `$${b.today.usd.toFixed(2)}` : `${Object.values(b.today.calls).reduce((x, y) => x + y, 0)} calls`} today. Answers already asked are reused for free.</p>
366
+ {b.creditsLeft !== null && <p class="lead">About ${b.creditsLeft.toFixed(2)} of Meta credits left (agent turns not counted).</p>}
367
+ <p class="note">Meta calls are priced from what each one used (Spark on the {b.sparkTier} tier).{b.elsewhere.calls ? ` Other models: ${b.elsewhere.calls} calls, $${b.elsewhere.usd.toFixed(2)}, not on Meta's bill.` : ''} Other models take dollars per call from their billing page.</p>
368
+ <div class="prices">
369
+ {models.map((m) => <label>{MODEL_NAMES[m]} · {b.total.calls[m] ?? 0} calls<input inputMode="decimal" placeholder="$ per call" value={prices[m] ?? ''} onInput={(e) => setPrices({ ...prices, [m]: e.currentTarget.value })} /></label>)}
370
+ <label title="Type the balance dev.meta.ai shows now; the Studio counts down from it">Meta credits now ($)<input inputMode="decimal" placeholder={b.creditsLeft === null ? '55.36' : b.creditsLeft.toFixed(2)} value={credits} onInput={(e) => setCredits(e.currentTarget.value)} /></label>
371
+ <label title="Studio shows a banner once the spend passes this">Warn at ($)<input inputMode="decimal" placeholder="150" value={limit} onInput={(e) => setLimit(e.currentTarget.value)} /></label>
372
+ </div>
373
+ <div class="row"><button type="submit" class="ghost small" title="Keep the prices and the limit on this computer">Save</button><span class="detail">{saved}</span></div>
374
+ </form>
375
+ );
376
+ }
377
+
378
+ /** Ideas to start from: a click puts one in the box. */
379
+ const EXAMPLES = [
380
+ 'A night market where paper lanterns hold lost memories, and one lantern has gone dark',
381
+ "My grandmother's kitchen in 1989, the evening before the house is sold",
382
+ 'A foggy harbour where a cat has stolen the last ferry ticket',
383
+ 'Ostatni tramwaj przez zamglony Kraków i pasażer, który nie pamięta, dokąd jedzie',
384
+ ];
385
+
386
+ type Photo = { name: string; data: string };
387
+
388
+ /** A job's error in a few plain words, for the chat; the whole error still goes to the agent. */
389
+ export function plainError(error: string | undefined): string {
390
+ const e = error ?? '';
391
+ if (/content_policy_violation|safety|moderat/i.test(e)) return 'the image safety filter refused a picture';
392
+ if (/invalid_api_key|401|unauthori[sz]ed/i.test(e)) return 'the Meta key was refused';
393
+ if (/\b429\b|rate.?limit|quota/i.test(e)) return 'too many model calls at once';
394
+ if (/timed? ?out|ETIMEDOUT|ECONNRESET|ENOTFOUND|fetch failed|socket hang up|\b50[234]\b/i.test(e)) return 'a model did not answer';
395
+ const first = e.replace(/^diorama \w+: /, '').replace(/\{.*$/s, '').split(/[.\n]/)[0].trim();
396
+ return first ? (first.length > 70 ? `${first.slice(0, 68)}…` : first) : 'see its output';
397
+ }
398
+ /** The languages the game's voices speak (ElevenLabs multilingual); Spark writes in all of them. */
399
+ const LANGUAGES = ['Arabic', 'Bulgarian', 'Chinese', 'Croatian', 'Czech', 'Danish', 'Dutch', 'English', 'Filipino', 'Finnish', 'French', 'German', 'Greek', 'Hindi', 'Indonesian', 'Italian', 'Japanese', 'Korean', 'Malay', 'Polish', 'Portuguese', 'Romanian', 'Russian', 'Slovak', 'Spanish', 'Swedish', 'Tamil', 'Turkish', 'Ukrainian'];
400
+
401
+ function useStart() {
402
+ const [worlds, setWorlds] = useState<Summary[] | null>(null);
403
+ const [needs, setNeeds] = useState<Check[]>([]);
404
+ const [error, setError] = useState('');
405
+ useEffect(() => {
406
+ call<Summary[]>('world_list').then(setWorlds).catch((e) => setError(e.message));
407
+ call<Check[]>('setup_status').then((c) => setNeeds(c.filter((x) => x.state === 'missing' || x.state === 'fail'))).catch(() => {});
408
+ }, []);
409
+ return { worlds, needs, error, setError };
410
+ }
411
+
412
+ function SetupBanner({ needs, pl }: { needs: Check[]; pl?: boolean }) {
413
+ if (!needs.length) return null;
414
+ return (
415
+ <a class="banner" href="/setup" onClick={(e) => (e.preventDefault(), go('/setup'))}>
416
+ {pl ? `Ustawienia: brakuje ${needs.map((n) => n.label).join(', ')}. Otwórz ustawienia →` : `Setup: ${needs.map((n) => n.label).join(', ')} ${needs.length > 1 ? 'are' : 'is'} missing. Open Setup →`}
417
+ </a>
418
+ );
419
+ }
420
+
421
+ /** The front door: one idea, in words, a voice or a photo; Spark picks the name, the places and the language. */
422
+ function Start() {
423
+ const { worlds, needs, error, setError } = useStart();
424
+ const noKey = needs.some((n) => n.id === 'META_API_KEY');
425
+ const [idea, setIdea] = useState('');
426
+ const [photos, setPhotos] = useState<Photo[]>([]);
427
+ const [loadingPhotos, setLoadingPhotos] = useState(0);
428
+ const [videoBusy, setVideoBusy] = useState(false);
429
+ const [videos, setVideos] = useState<string[]>([]);
430
+ const [videoReview, setVideoReview] = useState<VideoResult | null>(null);
431
+ const [slug, setSlug] = useState('');
432
+ const [places, setPlaces] = useState(0);
433
+ const [language, setLanguage] = useState('');
434
+ const [busy, setBusy] = useState(false);
435
+ const [reviewInput, setReviewInput] = useState<(ReviewInput & { slug?: string }) | null>(null);
436
+ const building = useRef(false);
437
+ const [drop, setDrop] = useState(false);
438
+ const [camera, setCamera] = useState(false);
439
+ const box = useRef<HTMLTextAreaElement>(null);
440
+ const taken = !!slug && worlds?.some((w) => w.slug === slug);
441
+ const tooManyPhotos = photos.length > MAX_WORLD_PHOTOS;
442
+ const ready = idea.trim().length >= 10 && idea.length <= 4000 && !taken && !noKey && !busy && !videoBusy && !loadingPhotos && !tooManyPhotos;
443
+ const addPhotos = async (files: Iterable<File>) => {
444
+ const pics = [...files].filter((f) => f.type.startsWith('image/'));
445
+ setLoadingPhotos((n) => n + 1);
446
+ try {
447
+ const kept = await Promise.all(pics.map(async (f) => ({ name: (f.name || 'pasted.png').replace(/\.[^.]+$/, '') + '.jpg', data: await shrink(f) })));
448
+ setPhotos((p) => [...p, ...kept]);
449
+ } catch (e) {
450
+ setError(`Could not read the selected photos; this batch was not added. ${(e as Error).message}`);
451
+ } finally {
452
+ setLoadingPhotos((n) => n - 1);
453
+ }
454
+ };
455
+ // A snap from the camera: shrunk like a picked photo, named in the order taken.
456
+ const snap = async (jpeg: string) => {
457
+ const bytes = Uint8Array.from(atob(jpeg.slice(jpeg.indexOf(',') + 1)), (c) => c.charCodeAt(0));
458
+ await addPhotos([new File([bytes], `photo-${photos.length + 1}.jpg`, { type: 'image/jpeg' })]);
459
+ };
460
+ const review = (e?: Event) => {
461
+ e?.preventDefault();
462
+ if (!ready || reviewInput) return;
463
+ setError('');
464
+ setReviewInput({ idea: idea.trim(), photos, videos, ...(slug && { slug }), ...(places && { places }), ...(language && { language }) });
465
+ };
466
+ const build = async (choices?: CreativeChoices, sketch?: import('../src/world/sketch.ts').SketchApproval) => {
467
+ if (!reviewInput || building.current) return;
468
+ building.current = true;
469
+ setError('');
470
+ setBusy(true);
471
+ try {
472
+ const made = await call<{ slug: string }>('world_new', { ...reviewInput, ...(choices && { choices }), ...(sketch && { sketch }) });
473
+ void call('world_list').then((w) => (allWorlds.value = w as typeof allWorlds.value)).catch(() => {});
474
+ go(`/w/${made.slug}`);
475
+ } catch (err) {
476
+ setError((err as Error).message);
477
+ setBusy(false);
478
+ building.current = false;
479
+ }
480
+ };
481
+ return (
482
+ <>
483
+ <main class={`start${reviewInput ? ' reviewing' : ''}`}>
484
+ <Corner />
485
+ <SetupBanner needs={needs} />
486
+ <form class="hero" hidden={!!reviewInput} onSubmit={review}>
487
+ <Logo size={44} />
488
+ <h1>What should the <em>world</em> be?</h1>
489
+ <p class="lead">{named('Say it in a sentence. Muse Spark writes the story, Muse Image paints the places, SAM 3.1 cuts out everything you can touch, and the agent looks after it with you.')}</p>
490
+ <div class={`prompt${drop ? ' drop' : ''}`}
491
+ onDragOver={(e) => (e.preventDefault(), setDrop(true))}
492
+ onDragLeave={() => setDrop(false)}
493
+ onDrop={(e) => (e.preventDefault(), setDrop(false), void addPhotos(e.dataTransfer?.files ?? []))}>
494
+ <textarea ref={box} disabled={videoBusy} rows={4} aria-label="Your idea" placeholder="A lighthouse keeper's last night before the foghorn is switched off for good…" value={idea}
495
+ onInput={(e) => setIdea(e.currentTarget.value)}
496
+ onPaste={(e) => { const files = [...(e.clipboardData?.files ?? [])]; if (files.length) (e.preventDefault(), void addPhotos(files)); }}
497
+ onKeyDown={(e) => e.key === 'Enter' && (e.metaKey || e.ctrlKey) && review(e)} />
498
+ {photos.length > 0 && (
499
+ <div class="attached">
500
+ {photos.map((p, i) => (
501
+ <span class="att" title={`${p.name}. Spark decides what it is: a real room becomes the first place, a person the hero, a drawing a character, thing or place of the game`}>
502
+ <button type="button" class="ghost bare" data-lightbox={p.data} aria-label="See it big"><img src={p.data} alt="" /></button>
503
+ <button type="button" class="ghost bare remove-photo" aria-label="Remove the photo" onClick={() => setPhotos(photos.filter((_, j) => j !== i))}>×</button>
504
+ </span>
505
+ ))}
506
+ </div>
507
+ )}
508
+ {photos.length > 0 && <p role="status">{photos.length} / {MAX_WORLD_PHOTOS} photos</p>}
509
+ {loadingPhotos > 0 && <p role="status">Reading photos…</p>}
510
+ {tooManyPhotos && <p class="error" role="alert">One world takes up to {MAX_WORLD_PHOTOS} photos. Remove {photos.length - MAX_WORLD_PHOTOS} to continue. All selected photos are still shown.</p>}
511
+ <div class="row">
512
+ <label class="button ghost bare" title="A photo of a real room becomes the first place, of a person the hero, of a drawing a character, thing or place. Dropping or pasting pictures works too">
513
+ <input type="file" accept="image/*" multiple onChange={(e) => (void addPhotos(e.currentTarget.files ?? []), (e.currentTarget.value = ''))} />+ Photo
514
+ </label>
515
+ <button type="button" class="ghost bare" title="Take photos with the camera, one after another: drawings, a room, a person" onClick={() => setCamera(true)}><Icon name="camera" /> Camera</button>
516
+ <SayIt onText={(text) => (setIdea((old) => `${old.trim()} ${text}`.trim()), box.current?.focus())} onError={setError} />
517
+ <VideoInput note={idea} disabled={busy || noKey} onBusy={setVideoBusy} onResult={(id, result) => {
518
+ setVideos(v => [...v, id]); setVideoReview(result);
519
+ setIdea(result.idea);
520
+ setPhotos(p => [...p, ...result.photos]);
521
+ }} />
522
+ <span class="grow" />
523
+ <button type="submit" class="primary" disabled={!ready}
524
+ title={taken ? 'A world has this name already: change it under More' : noKey ? 'Add the Meta key in Setup first' : idea.trim().length < 10 ? 'Write the idea first (at least 10 letters)' : 'Review optional questions and visual directions before building. You can skip them and build immediately.'}>
525
+ {busy ? 'Spark reads the idea…' : taken ? 'Name taken' : noKey ? 'Needs the Meta key' : 'Build it'}
526
+ </button>
527
+ </div>
528
+ </div>
529
+ {videoReview && <details open><summary>Review what the video means</summary>
530
+ <p>Edit the idea above and remove any reference pictures you do not want. Each picture’s name includes its time in the recording.</p>
531
+ {videoReview.idea.length > 4000 && <p class="error">The full interpretation exceeds the idea field. Condense it before building; the complete interpretation is shown below and kept with the video.</p>}
532
+ {videoReview.totalFrames > 32 && <p>{videoReview.totalFrames} useful frames were found; 32 spread across the movie are shown. All timestamped evidence is kept.</p>}
533
+ {!!videoReview.speech?.length && <details><summary>Recognized speech and timing</summary>{videoReview.speech.map(s => <div key={s.at}><p>{s.provider} · {s.model} · {s.timing === 'none' ? 'No speech timing returned' : 'Speech turn timestamps'}</p>{s.timing === 'none' ? <p>{s.text}</p> : s.segments.map(t => <p>{t.start.toFixed(1)}–{t.end.toFixed(1)} s {t.speaker ? `· Speaker ${t.speaker} (clip ${s.at}s)` : ''}: {t.text}</p>)}</div>)}</details>}
534
+ <details><summary>Full interpretation and observations</summary><p>{videoReview.idea}</p>{videoReview.observations.map(o => <p key={o.at}>{Math.floor(o.at / 60)}:{String(o.at % 60).padStart(2, '0')} · {o.text}</p>)}</details>
535
+ </details>}
536
+ <div class="row ideas">
537
+ {EXAMPLES.map((x) => <button type="button" class="chip" title="Start from this idea" onClick={() => (setIdea(x), box.current?.focus())}>{x}</button>)}
538
+ </div>
539
+ <details class="more">
540
+ <summary title="The world's name, how many places and the language: Spark picks them from the idea when left on Auto">More options</summary>
541
+ <div class="fields">
542
+ <label title="The world's name in links and folders: small letters, digits and dashes"><span>Name</span><input value={slug} placeholder="Auto" onInput={(e) => setSlug(e.currentTarget.value)} pattern="[a-z][a-z0-9\-]*" /></label>
543
+ <label title="How many places the game has. Each is about one Muse Image painting plus SAM cut-outs. Auto: as many as the story needs (2 to 5)"><span>Places</span><select value={places} onChange={(e) => setPlaces(Number(e.currentTarget.value))}><option value={0}>Auto</option>{[2, 3, 4, 5, 6].map((n) => <option value={n}>{n}</option>)}</select></label>
544
+ <label title="The language of the game's text and voices: every language the voices speak. Auto: the language the idea is written in"><span>Language</span><select value={language} onChange={(e) => setLanguage(e.currentTarget.value)}><option value="">Auto</option>{LANGUAGES.map((l) => <option value={l}>{l}</option>)}</select></label>
545
+ </div>
546
+ </details>
547
+ {error && <p class="error">{error}</p>}
548
+ </form>
549
+ {reviewInput && <IdeaReview input={reviewInput} busy={busy} error={error} onBuild={build} onBack={() => { setReviewInput(null); setError(''); }} />}
550
+ {camera && (
551
+ <div class="shoot" role="dialog" aria-label="Camera">
552
+ <Camera tip={photos.length >= MAX_WORLD_PHOTOS ? `One world takes up to ${MAX_WORLD_PHOTOS} photos. Remove extras before building.` : 'Hold each drawing flat, filling the picture. Take as many as you like, then Done.'} words={{ snap: photos.length ? `Take another (${photos.length})` : 'Take the photo', done: 'Done', none: 'No camera' }}
553
+ onSnap={(jpeg) => void snap(jpeg)} onClose={() => setCamera(false)} />
554
+ {photos.length > 0 && <div class="taken">{photos.map((p) => <img src={p.data} alt="" />)}</div>}
555
+ </div>
556
+ )}
557
+ </main>
558
+ </>
559
+ );
560
+ }
561
+
562
+ type Line = ActivityLine;
563
+
564
+ // ?replay=<session file> plays a recorded conversation back in the agent panel (for the film); ?speed=2 doubles it.
565
+ const query = new URLSearchParams(location.search);
566
+ const replay = query.get('replay');
567
+ const speed = Number(query.get('speed') ?? '1') || 1;
568
+
569
+ function Workshop({ slug }: { slug: string }) {
570
+ const [status, setStatus] = useState<Status | null>(null);
571
+ const [error, setError] = useState('');
572
+ const [live, setLive] = useState(false);
573
+ const [lines, setLines] = useState<Line[]>([]);
574
+ const [running, setRunning] = useState<Record<string, 'run' | 'fail'>>({});
575
+ /** Steps the running job finished, with their seconds; the last thing it printed. */
576
+ const [jobDone, setJobDone] = useState<Record<string, number>>({});
577
+ const [jobTail, setJobTail] = useState('');
578
+ // The story step's own progress (each draft, the checker's verdict, the dialogue review), for the Story view while it writes.
579
+ const [storyLog, setStoryLog] = useState<StoryProgress[]>([]);
580
+ // Places this job set aside, by the step that failed for them; a repair that makes one playable takes it off.
581
+ const [aside, setAside] = useState<Record<string, string>>({});
582
+ const [jobPlan, setJobPlan] = useState<string[] | null>(null);
583
+ /** What the last undo or redo put back, until the next job. */
584
+ const [undone, setUndone] = useState<string | null>(null);
585
+ const undoneJob = useRef<string | null>(null);
586
+ /** Activity shows what happened; what each step prints is there too, on request (the job card shows its last line). */
587
+ const [allLines, setAllLines] = useState(false);
588
+ const [stage, setStage] = useState<StageView | null>(null);
589
+ const [byHand, setByHand] = useState<string | null>(null);
590
+ const opened = useRef<string | null>(null);
591
+ if (byHand) opened.current = byHand;
592
+ const creatorBrief = useCreatorBrief(slug);
593
+ const worldRef = useRef<HTMLElement>(null);
594
+ const stageRef = useRef<HTMLElement>(null);
595
+ useEffect(() => {
596
+ if (!byHand) return;
597
+ const esc = (e: KeyboardEvent) => e.key === 'Escape' && setByHand(null);
598
+ addEventListener('keydown', esc);
599
+ return () => removeEventListener('keydown', esc);
600
+ }, [byHand]);
601
+ /** Activity, the raw record of what ran, opens as a drawer from the top bar. */
602
+ const [logOpen, setLogOpen] = useState(false);
603
+ const [notesOpen, setNotesOpen] = useState(false);
604
+ const [historyOpen, setHistoryOpen] = useState(false);
605
+ const [styleOpen, setStyleOpen] = useState(false);
606
+ useEffect(() => {
607
+ if (!historyOpen && !styleOpen) return;
608
+ const esc = (e: KeyboardEvent) => e.key === 'Escape' && (setHistoryOpen(false), setStyleOpen(false));
609
+ addEventListener('keydown', esc);
610
+ return () => removeEventListener('keydown', esc);
611
+ }, [historyOpen, styleOpen]);
612
+ useEffect(() => {
613
+ if (!notesOpen) return;
614
+ const esc = (e: KeyboardEvent) => e.key === 'Escape' && setNotesOpen(false);
615
+ addEventListener('keydown', esc);
616
+ return () => removeEventListener('keydown', esc);
617
+ }, [notesOpen]);
618
+ useEffect(() => {
619
+ if (!logOpen) return;
620
+ const esc = (e: KeyboardEvent) => e.key === 'Escape' && setLogOpen(false);
621
+ addEventListener('keydown', esc);
622
+ return () => removeEventListener('keydown', esc);
623
+ }, [logOpen]);
624
+ /** The agent that would have repaired the last build, when it is not installed. */
625
+ const [noAgent, setNoAgent] = useState<string | null>(null);
626
+ /** Places the build is mending right now, with what it tries. */
627
+ const [repairing, setRepairing] = useState<Record<string, string>>({});
628
+ const [calls, setCalls] = useState(0);
629
+ /** Model calls per job, for Activity. */
630
+ const [costs, setCosts] = useState<Record<string, Record<string, number>>>({});
631
+ /** For the build stage: when each running step began, calls per model this job, the last call, the place last changed. */
632
+ const [since, setSince] = useState<Record<string, number>>({});
633
+ const [jobCalls, setJobCalls] = useState<Record<string, number>>({});
634
+ const [lastCall, setLastCall] = useState<Live['lastCall']>(null);
635
+ const [extension, setExtension] = useState<{ job: string; progress: ExtensionProgress } | null>(null);
636
+ const [cut, setCut] = useState<Record<string, string[]>>({});
637
+ /** The characters the running job paints or models, picture by picture (the live cast view). */
638
+ const [castWork, setCastWork] = useState<CastWork>({});
639
+ const [livePlace, setLivePlace] = useState<{ key: string; at: number } | null>(null);
640
+ /** Per place, this job's own progress: its step now (or the last one, done), and its verdict once told. */
641
+ const [placeRun, setPlaceRun] = useState<PlaceRuns>({});
642
+ const [spin, setSpin] = useState(0);
643
+ const [tick, setTick] = useState(0);
644
+ /** Right after an undo the person can take it back. */
645
+ const [canRedo, setCanRedo] = useState(false);
646
+ const [playing, setPlaying] = useState<{ place?: string; round?: number; full?: boolean } | null>(null);
647
+ const [notes, setNotes] = useState<Note[]>([]);
648
+ const [budget, setBudget] = useState<Budget | null>(null);
649
+ /** The build a workshop world's children are watching, until the game starts or the grown-up hides the show. */
650
+
651
+ const refresh = () => {
652
+ setTick((t) => t + 1);
653
+ call<Note[]>('feedback_list', { slug }).then(setNotes).catch(() => {});
654
+ call<Budget>('budget').then(setBudget).catch(() => {});
655
+ return call<Status>('world_status', { slug }).then((s) => (setStatus(s), setError(''))).catch((e) => setError(e.status === 404 ? '' : e.message));
656
+ };
657
+ useEffect(() => {
658
+ refresh();
659
+ let timer: ReturnType<typeof setTimeout> | undefined;
660
+ const soon = () => {
661
+ clearTimeout(timer);
662
+ timer = setTimeout(refresh, 300);
663
+ };
664
+ // Each line keeps when it happened and the job it belongs to, so Activity can fold a job's steps under it.
665
+ const add = (e: StudioEvent, text: string, kind: string, more: Partial<Line> = {}) => setLines((l) => [...l.slice(-600), { seq: e.seq, text, kind, at: Date.parse(e.at) || Date.now(), ...(typeof e.job === 'string' && { job: e.job }), ...more }]);
666
+ const stop = follow(slug, (e) => {
667
+ switch (e.type) {
668
+ // Verify's notes on a playable place live in the place's inspector; Activity sums them up in one line.
669
+ case 'log': {
670
+ // place.verdict says it once, in words; the folder on this machine is nobody's business on a recording.
671
+ const line = String(e.line).replace(/\/(?:Users|home)\/[^/\s]+/g, '~');
672
+ if (/^\s+flag\s|^# |^calls sent:|^[a-z][a-z0-9-]*: (ok|\d+ error\(s\)), \d+ flag\(s\)$|^[a-z][a-z0-9-]*: playable, flagged and continued/.test(line)) return;
673
+ if (line.trim()) setJobTail(line.trim().slice(0, 160));
674
+ const tried = /^\s*story (try \d+|: kept)/.test(line);
675
+ if (tried) setStoryLog((l) => [...l, { at: Date.now(), text: line.trim().replace(/^story /, '').slice(0, 220), kind: 'check' }]);
676
+ const about = castLine(line);
677
+ if (about) setCastWork((w) => ({ ...w, [about[0]]: castTouched(w[about[0]], lineStep(about[1]), { said: about[1] }) }));
678
+ return add(e, line, e.stream === 'err' ? 'err' : 'out');
679
+ }
680
+ case 'build.start': return setJobPlan(e.steps as string[]);
681
+ case 'step.start': setJobDone((d) => { const n = { ...d }; delete n[String(e.step)]; return n; }); setSince((t) => ({ ...t, [String(e.step)]: Date.parse(e.at) })); setRunning((r) => ({ ...r, [String(e.step)]: 'run' })); return;
682
+ case 'step.end': setJobDone((d) => ({ ...d, [String(e.step)]: Number(e.seconds) })); setRunning((r) => { const n = { ...r }; delete n[String(e.step)]; return n; }); soon(); return add(e, `✓ ${e.step} · ${e.seconds}s`, 'step');
683
+ case 'step.fail': setRunning((r) => ({ ...r, [String(e.step)]: 'fail' })); return add(e, `✕ ${e.step}: ${e.error}`, 'err');
684
+ case 'extension.progress': {
685
+ if (typeof e.job === 'string' && ['plan', 'places', 'audio', 'install'].includes(String(e.phase))) {
686
+ const jobId = e.job;
687
+ setExtension(previous => ({ job: jobId, progress: { phase: e.phase as ExtensionProgress['phase'], ...(typeof e.place === 'string' && { place: e.place }), ...(typeof e.index === 'number' && { index: e.index }), ...(typeof e.total === 'number' && { total: e.total }), ...(e.task === 'characters' && { task: 'characters' }), newPlaces: Array.isArray(e.newPlaces) ? e.newPlaces.filter((key): key is string => typeof key === 'string') : previous?.job === jobId ? previous.progress.newPlaces : undefined } }));
688
+ }
689
+ return;
690
+ }
691
+ case 'call': {
692
+ // A mask from the memo is still a thing cut out: the stage fills its box either way.
693
+ const m = /^mask:([^/]+)\/([^~#]+)/.exec(String(e.label ?? ''));
694
+ if (m && e.model === 'sam') setCut((c) => ({ ...c, [m[1]]: [...new Set([...(c[m[1]] ?? []), m[2]])] }));
695
+ if (e.hit) return;
696
+ setCalls((c) => c + 1);
697
+ if (typeof e.job === 'string') setCosts((c) => ({ ...c, [e.job as string]: { ...c[e.job as string], [String(e.model)]: (c[e.job as string]?.[String(e.model)] ?? 0) + 1 } }));
698
+ const by = callBy(String(e.model), e.via);
699
+ setJobCalls((c) => ({ ...c, [by]: (c[by] ?? 0) + 1 }));
700
+ const draft = /^story(?:#(\d+)|-dialogue-review)$/.exec(String(e.label ?? ''));
701
+ if (draft) setStoryLog((l) => [...l, { at: Date.now(), by, text: draft[1] ? `draft ${draft[1]} written` : 'dialogue reviewed', kind: 'call' }]);
702
+ return setLastCall({ by, label: String(e.label ?? '').replace(/#\d+$/, ''), at: Date.now() });
703
+ }
704
+ case 'file.changed': {
705
+ // A new picture of a place puts that place on the stage.
706
+ const at = /^places\/([^/]+)\/(blockin|plan|painting|masks\/overlay|depth|walk)\.png$/.exec(String(e.file));
707
+ if (at && e.exists) setLivePlace({ key: at[1], at: Date.now() });
708
+ // A character's picture as it lands: the live cast view shows it.
709
+ const who = e.exists ? castFile(String(e.file)) : null;
710
+ if (who) setCastWork((w) => ({ ...w, [who[0]]: castTouched(w[who[0]], who[1], { file: String(e.file) }) }));
711
+ return soon();
712
+ }
713
+ case 'feedback.changed': return soon();
714
+ case 'job.start': case 'job.end': case 'job.queued': {
715
+ if (e.type === 'job.start') (setCanRedo(false), setJobDone({}), setJobTail(''), setJobPlan(null), setRunning({}), setUndone(null), setNoAgent(null), setSince({}), setJobCalls({}), setLastCall(null), setStoryLog([]), setAside({}), setPlaceRun({}), setExtension(null), setCut({}), setCastWork({}));
716
+ const job = e.job as Job;
717
+ soon();
718
+ // What the agent changed shows in an open game at once; after the person's own builds the banner offers it.
719
+ if (e.type === 'job.end' && job.by === 'agent' && job.state === 'done') setPlaying((p) => (p ? { ...p, round: (p.round ?? 0) + 1 } : p));
720
+ return add(e, `${e.type === 'job.end' ? (job.state === 'done' ? 'done' : job.state) : e.type === 'job.start' ? 'started' : 'queued'}: ${job.label} (by ${job.by})${job.error ? ` · ${job.error}` : ''}`, e.type === 'job.end' && job.state !== 'done' ? 'err' : 'job', { job: job.id, info: job as ActivityJob });
721
+ }
722
+ case 'history.undo': {
723
+ soon();
724
+ setCanRedo(!e.redo);
725
+ // A redo puts back what the undo took away, so it is named the same.
726
+ if (!e.redo) undoneJob.current = String(e.label ?? '').replace(/^before /, '') || 'the last job';
727
+ const files = (e.restored as string[]).length + (e.removed as string[]).length;
728
+ setUndone(`${e.by === 'agent' ? `The agent ${e.redo ? 'redid' : 'undid'}` : e.redo ? 'Redone' : 'Undone'}: ${undoneJob.current ?? 'the last job'} · ${files} file${files === 1 ? '' : 's'}`);
729
+ setPlaying((p) => (p ? { ...p, round: (p.round ?? 0) + 1 } : p));
730
+ return add(e, `${e.by === 'agent' ? 'agent' : 'you'}: ${e.redo ? 'redo' : `undo to ${e.id}`} · ${(e.restored as string[]).length} restored, ${(e.removed as string[]).length} removed`, e.by === 'agent' ? 'agent' : 'act');
731
+ }
732
+ // The build mending itself: free fixes first, then the agent is asked.
733
+ case 'place.step': setPlaceRun((r) => ({ ...r, [String(e.place)]: { ...r[String(e.place)], step: String(e.step), done: !!e.done, at: Date.parse(e.at) || Date.now() } })); return;
734
+ case 'place.fail': setPlaceRun((r) => ({ ...r, [String(e.place)]: { ...r[String(e.place)], step: String(e.step), done: true, at: Date.parse(e.at) || Date.now(), ok: false } })); setAside((a) => ({ ...a, [String(e.place)]: String(e.step) })); return add(e, `✕ ${e.place}: ${e.step} failed, set aside · ${e.error}`, 'err');
735
+ // A place is told playable as soon as it is done, while the build goes on with the rest: show it now.
736
+ case 'place.verdict': setPlaceRun((r) => ({ ...r, [String(e.place)]: { ...(r[String(e.place)] ?? { step: 'verify', at: Date.parse(e.at) || Date.now() }), done: true, ok: !!e.ok } })); soon(); if (e.ok) { const n = (e.flagged as string[] | undefined)?.length ?? 0; return add(e, `✓ ${e.place}: playable${n ? ` · ${n} note${n === 1 ? '' : 's'}, nothing to do (open the place to read them)` : ''}`, 'step'); } return add(e, `✕ ${e.place}: not playable · ${(e.errors as string[] | undefined)?.join('; ') || 'see verify'}`, 'err');
737
+ case 'repair.start': setRepairing((r) => ({ ...r, [String(e.place)]: `repairing: ${(e.fixes as string[]).join('; ')}` })); return add(e, `↻ ${e.place}: repairing · ${(e.fixes as string[]).join('; ')}`, 'step');
738
+ case 'repair.end': setPlaceRun((r) => ({ ...r, [String(e.place)]: { ...(r[String(e.place)] ?? { step: 'verify', at: Date.parse(e.at) || Date.now() }), done: true, ok: !!e.ok } })); if (e.ok) setAside((a) => { const n = { ...a }; delete n[String(e.place)]; return n; }); setRepairing((r) => { const n = { ...r }; delete n[String(e.place)]; return n; }); soon(); return add(e, `${e.ok ? '✓' : '✕'} ${e.place}: ${e.ok ? 'repaired, playable' : 'still not playable, left for the agent'}`, e.ok ? 'step' : 'err');
739
+ case 'repair.agent': {
740
+ // Places left unplayable, or (no places) the build that broke, at its step.
741
+ const what = (e.places as string[]).length ? (e.places as string[]).join(', ') : `the failed build${e.step ? ` (${e.step})` : ''}`;
742
+ if (e.missing) return (setNoAgent(String(e.missing)), add(e, `${e.missing} is not installed, so no agent can repair ${what}: see Setup`, 'err'));
743
+ return add(e, `agent asked to repair ${what}`, 'agent');
744
+ }
745
+ case 'lock.changed': return add(e, `${e.by === 'agent' ? 'agent' : 'you'}: ${e.locked ? 'locked' : 'unlocked'} ${e.place ?? 'the world'}`, 'act');
746
+ case 'action.start': if (quiet.has(String(e.action)) && e.by !== 'agent') return; return add(e, `${e.by === 'agent' ? 'agent' : 'you'}: ${e.action}`, e.by === 'agent' ? 'agent' : 'act', { who: e.by === 'agent' ? 'agent' : 'you', act: String(e.action) });
747
+ case 'ui.focus':
748
+ if (e.by !== 'agent') return;
749
+ // A reload of the open game is always fine; moving the view only when the person follows the agent.
750
+ if (e.view === 'play' && e.reload) setPlaying((p) => (p ? { ...p, round: (p.round ?? 0) + 1 } : followAgent.value ? { place: e.place as string | undefined } : p));
751
+ if (!followAgent.value) return;
752
+ if (e.view === 'play' && !e.reload) setPlaying((p) => ({ place: e.place as string | undefined, round: (p?.round ?? 0) + 1 }));
753
+ if (e.view === 'story' || e.view === 'cast') (setPlaying(null), setStage({ kind: e.view }));
754
+ if (e.view === 'workshop') (setPlaying(null), setStage(null));
755
+ if (e.place && e.view !== 'play') (setPlaying(null), setStage({ kind: 'place', place: String(e.place) }));
756
+ if (e.view === 'setup') go('/setup');
757
+ if (e.view === 'start') go('/');
758
+ return;
759
+ }
760
+ }, setLive);
761
+ return () => (stop(), clearTimeout(timer));
762
+ }, [slug]);
763
+
764
+ const job = status?.jobs.findLast((j) => j.state === 'running' || j.state === 'queued');
765
+ const last = status?.jobs.at(-1);
766
+ const failed = last && last.state === 'failed' ? last : null;
767
+ const pipelineJob = job ?? (failed?.args[0] === 'extend' ? failed : undefined);
768
+
769
+ const playable = !!status?.world && status.placeDetail.some((p) => p.key === status.world!.start && p.stages.painting && p.stages.geometry && p.verdict?.ok && !p.trouble);
770
+ // A build, or one step run on its own (the agent's story_redo): the stage shows its bar.
771
+ const building = job && (job.args[0] === 'build' || steps.includes(job.args[0])) ? job : null;
772
+ const shown = job;
773
+ // How far the watched build is, weighed by how long each step took last time.
774
+ const took = (s: string) => status?.build[s]?.seconds ?? status?.typical?.[s] ?? 30;
775
+ // A running step counts as far along as its time so far says (never quite done), so the road moves every second, not only between steps.
776
+ const [now, setNow] = useState(Date.now());
777
+ useEffect(() => {
778
+ if (!shown || shown.state !== 'running') return;
779
+ const timer = setInterval(() => setNow(Date.now()), 1000);
780
+ return () => clearInterval(timer);
781
+ }, [shown?.id, shown?.state]);
782
+ // Any other job (a playthrough, one character's model, a voice) takes about what the same job took before here;
783
+ // with none before, no guess: the whole build's steps are not what it runs.
784
+ const before = job && job.args[0] !== 'build' ? (status?.jobs ?? []).filter((j) => j.id !== job.id && j.state === 'done' && j.args[0] === job.args[0] && j.startedAt && j.endedAt).slice(-3).map((j) => (Date.parse(j.endedAt!) - Date.parse(j.startedAt!)) / 1000) : [];
785
+ const plan = jobPlan ?? (job?.args[0] === 'build' ? steps : []);
786
+ const total = before.length ? before.reduce((a, b) => a + b, 0) / before.length : plan.reduce((sum, s) => sum + took(s), 0);
787
+ const left = before.length
788
+ ? Math.max(0, total - (job?.startedAt ? (now - Date.parse(job.startedAt)) / 1000 : 0))
789
+ : plan.filter((s) => jobDone[s] === undefined).reduce((sum, s) => sum + took(s) - (running[s] === 'run' && since[s] ? Math.min((now - since[s]) / 1000, took(s) * 0.9) : 0), 0);
790
+ // The build stage: the running job's own steps (the build says them; until then, its arguments).
791
+ const arg = (name: string) => (job && job.args.includes(name) ? job.args[job.args.indexOf(name) + 1] : undefined);
792
+ const ownPlan = jobPlan ?? (job?.args[0] === 'build' ? steps.slice(arg('--from') ? steps.indexOf(arg('--from')!) : 0, arg('--to') ? steps.indexOf(arg('--to')!) + 1 : steps.length) : []);
793
+ // A job that runs one step on its own (the agent's story_redo runs `story`, a cast redo `cast`) says no steps:
794
+ // its command is the step, running since the job started.
795
+ const lone = job && job.args[0] !== 'build' && steps.includes(job.args[0]) ? job.args[0] : null;
796
+ const loneRuns = !!lone && job!.state === 'running' && jobDone[lone] === undefined && !Object.values(running).includes('run');
797
+ // The world's calls so far, every job together, named as the live counts are (Claude standing in for Spark).
798
+ const worldCalls: Record<string, number> = {};
799
+ for (const [key, n] of Object.entries(status?.callsVia ?? {})) {
800
+ const [model, via] = key.split('|');
801
+ const by = callBy(model, via || undefined);
802
+ worldCalls[by] = (worldCalls[by] ?? 0) + n;
803
+ }
804
+ const liveBuild: Live = {
805
+ plan: job ? (ownPlan.length ? ownPlan : lone ? [lone] : []) : [],
806
+ done: jobDone,
807
+ running: loneRuns ? { ...running, [lone!]: 'run' } : running,
808
+ since: loneRuns && job!.startedAt ? { ...since, [lone!]: Date.parse(job!.startedAt) } : since,
809
+ calls: jobCalls, lastCall, tail: jobTail, cut, aside, places: placeRun,
810
+ extension: job && extension?.job === job.id ? { ...extension.progress, newPlaces: extension.progress.newPlaces ?? job.extension?.newPlaces } : job?.extension,
811
+ };
812
+ // On stage: the place picked; else a new picture as it lands; else, in turn, the places a step is working on.
813
+ const stepping = building ? (status?.placeDetail ?? []).filter((p) => placeRun[p.key] && !placeRun[p.key].done).map((p) => p.key) : [];
814
+ const busy = stepping.length ? stepping : building ? (status?.placeDetail ?? []).filter((p) => working(p, liveBuild)).map((p) => p.key) : [];
815
+ useEffect(() => {
816
+ if (!building) return;
817
+ const timer = setInterval(() => setSpin((n) => n + 1), 4500);
818
+ return () => clearInterval(timer);
819
+ }, [!!building]);
820
+ const fresh = livePlace && Date.now() - livePlace.at < 4000 ? livePlace.key : null;
821
+ const watchedPlace = stage?.kind === 'build' && status?.placeDetail.some(p => p.key === stage.place) ? stage.place : undefined;
822
+ const stageKey = watchedPlace ?? fresh ?? (busy.length ? busy[spin % busy.length] : null) ?? livePlace?.key ?? status?.placeDetail[0]?.key;
823
+ const stagePlace = status?.placeDetail.find((p) => p.key === stageKey);
824
+ // A click on the build bar: the story, the cast or the game, or the place layer the step made.
825
+ const [hold, setHold] = useState<{ layer: LayerName; at: number } | null>(null);
826
+ const openPhase = (phase: string, step?: string) => {
827
+ if (phase === 'story') return onStage({ kind: 'story' });
828
+ if (phase === 'cast') {
829
+ // A cast step at work (or just done): watch it live; otherwise the cast as it stands.
830
+ const s = step && CAST_STEPS.includes(step) ? step : CAST_STEPS.find((x) => liveBuild.running[x] === 'run') ?? (job ? CAST_STEPS.find((x) => liveBuild.plan.includes(x)) : undefined);
831
+ const seen = !!s && Object.values(castWork).some((w) => Object.keys(w.files).some((f) => castFile(f)?.[1] === s));
832
+ // Talking faces always open in the build view: who speaks, who failed and why, their mouth shapes.
833
+ return onStage(s && (s === 'faces' || liveBuild.plan.includes(s) || seen) ? { kind: 'work', step: s } : { kind: 'cast' });
834
+ }
835
+ // World-wide steps with no place layer (item icons, plates, weather, reactions, sounds): their own live view.
836
+ if (step && WORK_STEPS.includes(step)) return onStage({ kind: 'work', step });
837
+ if (phase === 'sound') return step === 'voices' ? setByHand('#cast') : onStage({ kind: 'work', step: 'sound' });
838
+ if (phase === 'verify') return playable ? onStage({ kind: 'play' }) : setLogOpen(true);
839
+ const layer = (step && STEP_SHOWS[step]) ?? ({ paint: 'painting', cut: 'masks', space: 'walk' } as Record<string, LayerName>)[phase];
840
+ if (layer) {
841
+ setHold({ layer, at: Date.now() });
842
+ if (shownView.kind !== 'build') onStage({ kind: 'build', place: shownView.build?.place });
843
+ }
844
+ };
845
+ // A click on the running job's card: the build view of the step it is on now, on its place or character, as the
846
+ // bar's segment for that step would open it ("cast mara" opens the cast view on mara, "repaint quay" the quay).
847
+ const openJob = () => {
848
+ if (!job) return onStage({ kind: 'build' });
849
+ const step = Object.keys(liveBuild.running).find((x) => liveBuild.running[x] === 'run') ?? lone ?? (STEP_PHASE[job.args[0]] ? job.args[0] : undefined);
850
+ const place = job.args.find((a) => places.some((p) => p.key === a));
851
+ if (!step) return onStage({ kind: 'build', place });
852
+ const phase = STEP_PHASE[step];
853
+ openPhase(phase, step);
854
+ // A step that shows a place layer: on the place this job works on.
855
+ if (place && STEP_SHOWS[step] && !WORK_STEPS.includes(step)) onStage({ kind: 'build', place });
856
+ };
857
+ const playLine = (text: string) => setLines((l) => [...l.slice(-600), { seq: -Date.now() - Math.random(), text, kind: 'play', at: Date.now() }]);
858
+
859
+ const places = status?.placeDetail ?? [];
860
+ const pendingPlaces = job?.args[0] === 'extend' ? (liveBuild.extension?.newPlaces ?? []).filter(key => !places.some(place => place.key === key)) : [];
861
+ const shownView: StageView = playing ? { kind: 'play' } : stage ?? (building ? { kind: 'build' } : { kind: 'place', place: places[0]?.key });
862
+ const onStage = (s: StageView) => {
863
+ // Story and cast opened from the build remain within it, including its manual place choice.
864
+ const buildContext = shownView.kind === 'build' ? { place: watchedPlace } : shownView.build;
865
+ const destination = ['story', 'cast', 'work'].includes(s.kind) && buildContext ? { ...s, build: buildContext } : s;
866
+ setStage(s.kind === 'play' ? null : destination);
867
+ if (s.kind === 'play') setPlaying((p) => p ?? { place: undefined });
868
+ else setPlaying(null);
869
+ };
870
+ const stagedPlace = shownView.kind === 'place' ? places.find((p) => p.key === shownView.place) ?? places[0] : undefined;
871
+ const unplayable = !job ? places.filter((p) => p.trouble) : [];
872
+ const openNotes = notes.filter((n) => n.state === 'open');
873
+ const misses = status?.audioMisses && status.audioMisses.sounds.length + status.audioMisses.lines.length > 0 ? status.audioMisses : null;
874
+ const ask = (text: string, gist?: string) => askAgent(slug, text, gist).catch((e) => setError(e.message));
875
+ const castDone = !!status?.steps.includes('cast');
876
+ // What the conversation opens with: the idea, then the world as the build made it, card by card.
877
+ const opening = [
878
+ creatorBrief?.idea && { id: 'idea', afterTurn: 0, text: <CreatorIdea brief={creatorBrief} /> },
879
+ status?.title && status.steps.includes('story') && { id: 'story', afterTurn: 0, text: (
880
+ <Card kind="Story" by="Muse Spark" on={shownView.kind === 'story'} onClick={() => onStage({ kind: 'story' })} opening>
881
+ <h3>{status.title}</h3>
882
+ {status.pitch && <p>{status.pitch}</p>}
883
+ </Card>
884
+ ) },
885
+ places.length > 0 && { id: 'places', afterTurn: 0, text: <PlacesCard slug={slug} places={places} pending={pendingPlaces} stage={shownView} onStage={onStage} /> },
886
+ castDone && { id: 'cast', afterTurn: 0, text: <CastCard slug={slug} version={tick} stage={shownView} onStage={onStage} /> },
887
+ ].filter(Boolean) as ChatNotice[];
888
+ // What a turn of the agent changed: the places it touched, to look at, and its undo.
889
+ const afterTurn = (turn: SessionTurn, _index: number, last: boolean) => {
890
+ // While the agent works its steps fold into one line; what it last said stays out in the open.
891
+ if (!turn.endedAt) {
892
+ const said = turn.entries.findLast((e) => e.kind === 'text');
893
+ return said && said !== turn.entries.at(-1) && said.kind === 'text' && said.text.trim() ? <p class="agent-says">{said.text}</p> : null;
894
+ }
895
+ const touched = [...new Set(turn.entries.flatMap((e) => (e.kind === 'tool' && e.name.startsWith('mcp__diorama__') && !quiet.has(e.name.slice(14)) && e.input && typeof (e.input as { place?: unknown }).place === 'string' ? [String((e.input as { place: string }).place)] : [])))];
896
+ const question = last && turn.endedAt ? asked(turn) : null;
897
+ const changed = touched.length > 0 && turn.endedAt ? (
898
+ <Card kind="Changed" on={shownView.kind === 'place' && touched.includes(shownView.place ?? '')}>
899
+ <div class="thumbs">{touched.map((k) => { const p = places.find((x) => x.key === k); return <button type="button" class="thumb" title={`Show ${nice(k)}`} onClick={() => onStage({ kind: 'place', place: k })} style={p?.image ? { backgroundImage: `url("${fileUrl(slug, p.image.file, p.image.v)}")` } : undefined}>{!p?.image && <span>{nice(k)}</span>}</button>; })}</div>
900
+ <div class="btns"><button type="button" class="ghost small" title="Play the game to see it. Free" onClick={() => onStage({ kind: 'play' })}>▶ Try it</button>{last && <button type="button" class="ghost small" title="Take back the last job. Free" onClick={() => call('history_undo', { slug }).catch((e) => setError(e.message))}>↶ Undo</button>}</div>
901
+ </Card>
902
+ ) : null;
903
+ if (!changed && !question) return null;
904
+ return (
905
+ <>
906
+ {changed}
907
+ {question && (
908
+ <Card kind="The agent asks" tone="ask">
909
+ {question.question && <h3>{question.question}</h3>}
910
+ <div class="btns">{question.options.map((o, i) => <button type="button" class={i === question.recommended ? 'primary small' : 'ghost small'} title={i === question.recommended ? 'What the agent advises' : undefined} onClick={() => void ask(o)}>{o}</button>)}</div>
911
+ </Card>
912
+ )}
913
+ </>
914
+ );
915
+ };
916
+ // A playthrough says how many endings it plays and how each went: the card counts them and guesses the rest by the ones done.
917
+ const trial = job?.args[0] === 'playthrough' ? (() => {
918
+ const mine = lines.filter((l) => l.job === job.id && !l.info).map((l) => ({ text: l.text.trim(), at: l.at ?? now }));
919
+ const total = Number(/^playing (\d+) ending/.exec(mine.find((l) => /^playing \d+ ending/.test(l.text))?.text ?? '')?.[1] ?? 0);
920
+ const results = mine.flatMap((l) => { const m = /^(ok|FAIL)\s+([\w-]+): .*\((\d+) s wall\)/.exec(l.text); return m ? [{ ok: m[1] === 'ok', ending: m[2], wall: Number(m[3]) }] : []; });
921
+ const playing = mine.findLast((l) => /^\d+\/\d+ playing to /.test(l.text));
922
+ const each = results.length ? results.reduce((a, r) => a + r.wall, 0) / results.length : 0;
923
+ const left = each && total ? Math.max(0, each * (total - results.length) - (playing && results.length < total ? (now - playing.at) / 1000 : 0)) : 0;
924
+ return { total, done: results.length, results, now: results.length < total ? playing?.text.replace(/^\d+\/\d+ playing to /, '') : undefined, left };
925
+ })() : null;
926
+ // While the agent works, its progress is the last thing above the prompt: what waits for you comes back after.
927
+ const footer = (agentBusy: boolean) => (
928
+ <div class="world-footer">
929
+ {movedFrom.value && movedFrom.value !== slug && <Card kind="Moved here" tone="ask"><p>The agent moved the view here from {movedFrom.value}.</p><div class="btns"><button type="button" class="ghost small" onClick={() => { const back = movedFrom.value!; movedFrom.value = null; go(`/w/${back}`); }}>Back to {movedFrom.value}</button><button type="button" class="ghost small" onClick={() => (movedFrom.value = null)}>Stay</button></div></Card>}
930
+ {job && (
931
+ trial ? (
932
+ <Card kind={job.by === 'agent' ? 'The agent is testing the game' : 'Testing the game'} by="a test bot" on={false}>
933
+ <p>A bot plays the game to {trial.total === 1 ? 'its ending' : 'every ending'} in a hidden browser, to check {trial.total === 1 ? 'it' : 'each'} can be reached. Free: no model calls. Keep working, or stop it.</p>
934
+ <p>{trial.total ? `Ending ${Math.min(trial.done + 1, trial.total)} of ${trial.total}${trial.now ? `: ${nice(trial.now)}` : ''}` : 'Starting…'}{trial.left ? (trial.left > 60 ? ` · about ${Math.round(trial.left / 60)} min left` : ' · almost done') : ''}</p>
935
+ <div class="bar"><i style={{ width: `${Math.round((trial.total ? trial.done / trial.total : 0.03) * 100)}%` }} /></div>
936
+ {trial.results.length > 0 && <p class="detail">{trial.results.map((r) => `${r.ok ? '✓' : '✕'} ${nice(r.ending)}`).join(' · ')}</p>}
937
+ <div class="btns"><button type="button" class="ghost small" title="Stop the test. Nothing in the world changes" onClick={(e) => (e.stopPropagation(), call('job_cancel', { id: job.id }))}>Stop</button></div>
938
+ </Card>
939
+ ) : (
940
+ <Card kind={job.by === 'agent' ? 'The agent is building' : 'Building'} by={MODEL_FOR[liveBuild.plan.find((s) => running[s] === 'run') ?? ''] } on={shownView.kind === 'build' || shownView.kind === 'work'} onClick={openJob}>
941
+ {job.args[0] === 'extend' ? <p>{extensionDetail(liveBuild.extension, Object.keys(liveBuild.running).find(s => liveBuild.running[s] === 'run') ?? job.step)}</p> : <>
942
+ <p>{job.label}{job.step ? `: ${job.step}` : ''}{total ? (left > 30 ? ` · about ${Math.max(1, Math.round(left / 60))} min left` : left > 0 ? ' · almost done' : ' · longer than last time') : ''}</p>
943
+ <div class="bar"><i style={{ width: `${Math.round((total ? 1 - left / total : 0.05) * 100)}%` }} /></div>
944
+ </>}
945
+ {jobTail && <p class="detail">{jobTail}</p>}
946
+ <div class="btns"><button type="button" class="ghost small" title={`Stop ${job.label}`} onClick={(e) => (e.stopPropagation(), call('job_cancel', { id: job.id }))}>Stop</button></div>
947
+ </Card>
948
+ )
949
+ )}
950
+ {failed && !job && !agentBusy && <Card kind="Needs you" tone="ask"><Failed slug={slug} job={failed} noAgent={noAgent} onError={setError} onPlace={(p) => onStage({ kind: 'place', place: p })} /></Card>}
951
+ {unplayable.length > 0 && !failed && !agentBusy && (
952
+ <Card kind="Needs you" tone="ask">
953
+ {unplayable.map((p) => <p><button type="button" class="link" onClick={() => onStage({ kind: 'place', place: p.key })}>{nice(p.key)}</button>: {p.trouble!.why}</p>)}
954
+ <div class="btns"><button type="button" class="primary small" title="The agent fixes them with the Studio tools and says what it did. It uses model calls as it works" onClick={() => void ask(`${unplayable.map((p) => `${p.key}: ${p.trouble!.why}`).join('; ')}. Fix ${unplayable.length > 1 ? 'these places' : 'this place'} as your playbook says and tell me in one sentence what you did.`, `Fix ${unplayable.length > 1 ? `${unplayable.length} places that cannot be played` : `${nice(unplayable[0].key)}: it cannot be played`}`)}>Fix it</button></div>
955
+ </Card>
956
+ )}
957
+ {misses && !job && !agentBusy && (
958
+ <Card kind="Audio incomplete">
959
+ <AudioMissing misses={misses} retry={() => {
960
+ const one = !misses.sounds.length && misses.lines.length === 1 && !/waiting for a designed voice/i.test(misses.lines[0].reason) ? misses.lines[0] : null;
961
+ void (one ? call('voice_retry', { slug, key: one.speaker, text: one.text }) : call('world_build', { slug, from: misses.sounds.length ? 'sound' : 'voices', to: 'voices' })).catch((e) => setError(e.message));
962
+ }} />
963
+ </Card>
964
+ )}
965
+ {openNotes.length > 0 && !job && !agentBusy && (
966
+ <Card kind={`${openNotes.every((n) => n.by === 'agent') ? 'The agent noted' : openNotes.some((n) => n.by === 'agent') ? 'Open notes' : 'Your notes'} · ${openNotes.length}`}>
967
+ {openNotes.slice(0, 3).map((n) => <p>{n.by === 'agent' && openNotes.some((x) => x.by !== 'agent') ? <em>the agent: </em> : ''}{n.place ? `${nice(n.place)}: ` : ''}{n.text}</p>)}
968
+ <div class="btns"><button type="button" class="ghost small" onClick={() => setNotesOpen(true)}>All notes</button><button type="button" class="ghost small" title="The agent fixes each open note and closes it with what it did" onClick={() => void ask(`Go through my ${openNotes.length} open note${openNotes.length > 1 ? 's' : ''} (feedback_pending): fix each one and close it with feedback_done.`, `Fix my ${openNotes.length} open note${openNotes.length > 1 ? 's' : ''}`)}>Hand to the agent</button></div>
969
+ </Card>
970
+ )}
971
+ {undone && !job && <p class="world-note">{undone}{canRedo ? ' · ' : ''}{canRedo && <button type="button" class="link" onClick={() => call('history_redo', { slug }).catch((e) => setError(e.message))}>Redo</button>}</p>}
972
+ {budget?.over && <p class="world-note">{budget.creditsLeft !== null && budget.creditsLeft <= 5 ? `Meta credits nearly gone: about $${budget.creditsLeft.toFixed(2)} left.` : `Budget: $${budget.total.usd} spent of the $${budget.limitUsd} limit.`}</p>}
973
+ {playable && !job && !agentBusy && (
974
+ <div class={`wcard play${shownView.kind === 'play' ? ' on' : ''}`} style={places[0]?.image ? { backgroundImage: `url("${fileUrl(slug, places[0].image.file, places[0].image.v)}")` } : undefined}>
975
+ <div class="in"><div class="grow"><div class="k">Ready</div><h3>Play {status?.title ?? slug}</h3></div><button type="button" class="amber" onClick={() => onStage({ kind: 'play' })}>▶ Play</button></div>
976
+ </div>
977
+ )}
978
+ {error && <p class="error">{error}</p>}
979
+ </div>
980
+ );
981
+ // The tab says which world it is.
982
+ useEffect(() => {
983
+ document.title = `${status?.title ?? slug} · Diorama`;
984
+ return () => void (document.title = 'Diorama Studio');
985
+ }, [status?.title, slug]);
986
+ const stageTitle = shownView.kind === 'place' ? nice(stagedPlace?.key ?? '') : shownView.kind === 'work' && WORK_STEPS.includes(shownView.step ?? '') ? 'Building' : shownView.kind === 'cast' || shownView.kind === 'work' ? 'Cast' : shownView.kind === 'story' ? 'Story' : shownView.kind === 'build' ? (stagePlace ? nice(stagePlace.key) : 'Building') : status?.title ?? slug;
987
+ const stageHint = shownView.kind === 'play' ? 'Click to walk, right click to look, or a gamepad' : '';
988
+ // What is open, told to the agent with each message so "this" means something; nothing shows for it.
989
+ // The last thing opened stays meant after its drawer closes, while the stage still shows where it is.
990
+ const fits = (o: string | null) => !!o && (o.startsWith('#cast') ? shownView.kind === 'cast' : shownView.kind === 'place' && o.split('@')[0] === stagedPlace?.key);
991
+ const openNow = byHand ?? (fits(opened.current) ? opened.current : null);
992
+ const onScreen = openNow
993
+ ? openNow.startsWith('#cast') ? (openNow.split(':')[1] ? `cast / ${openNow.split(':')[1].replace(/^item-/, 'item ')}` : 'cast') : openNow.replace('@', ' / ')
994
+ : shownView.kind === 'place' ? stagedPlace?.key ?? null : shownView.kind === 'cast' ? 'cast' : null;
995
+ // An inspection opened from Build returns to Build; standalone views return to the last place.
996
+ const away = shownView.kind === 'cast' || shownView.kind === 'story' || shownView.kind === 'work' || (shownView.kind === 'build' && !building);
997
+ const lastPlace = useRef<string | undefined>(undefined);
998
+ if (shownView.kind === 'place') lastPlace.current = shownView.place;
999
+ const back = () => shownView.build ? onStage({ kind: 'build', place: shownView.build.place }) : (lastPlace.current && places.some((p) => p.key === lastPlace.current) ? onStage({ kind: 'place', place: lastPlace.current }) : setStage(null));
1000
+ useEffect(() => {
1001
+ if (!away || byHand || notesOpen || logOpen || historyOpen || styleOpen) return;
1002
+ const esc = (e: KeyboardEvent) => e.key === 'Escape' && !(e.target instanceof HTMLElement && e.target.closest('input, textarea, select')) && back();
1003
+ addEventListener('keydown', esc);
1004
+ return () => removeEventListener('keydown', esc);
1005
+ }, [away, shownView.kind, shownView.build, byHand, notesOpen, logOpen, historyOpen, styleOpen]);
1006
+
1007
+ return (
1008
+ <>
1009
+ <main class="world" ref={worldRef}>
1010
+ <section class="convo">
1011
+ <header>
1012
+ <h1 title={status?.pitch ?? undefined}>{status?.title ?? slug}</h1>
1013
+ <small class="to-build" role="button" tabIndex={0} title="The build bar: every step, clickable" onClick={() => onStage({ kind: 'build' })}>{places.length ? `${places.length} places` : 'being made'}{status && status.playable ? ` · ${status.playable} playable` : ''}{job ? ' · building' : playable ? ' · ready to play' : ''}{live ? '' : ' · reconnecting…'}</small>
1014
+ </header>
1015
+ <AgentPanel slug={slug} replay={replay} speed={speed} jobs={status?.jobs ?? []} notices={opening} pinned={places.length > 0 && <WorldStrip slug={slug} version={tick} title={status?.title} places={places} pending={pendingPlaces} castDone={castDone} stage={shownView} onStage={onStage} />} afterTurn={afterTurn} footer={footer} onScreen={onScreen}
1016
+ empty={<span />} />
1017
+ </section>
1018
+ <section class="theatre" ref={stageRef}>
1019
+ <div class="stage-top">
1020
+ {away && <button type="button" class="ghost back" aria-label="Back" title={`Back to ${shownView.build ? 'the build' : lastPlace.current ? nice(lastPlace.current) : building ? 'the build' : 'the places'} (Esc)`} onClick={back}>←</button>}
1021
+ <h2>{stageTitle}</h2>
1022
+ {stageHint && <small>{stageHint}</small>}
1023
+ <span class="grow" />
1024
+ {playable && <button type="button" class={shownView.kind === 'play' ? 'ghost' : 'amber'} title={shownView.kind === 'play' ? 'Close the game' : 'Play the game here. Free'} onClick={() => onStage(shownView.kind === 'play' ? { kind: 'place', place: places[0]?.key } : { kind: 'play' })}>{shownView.kind === 'play' ? 'Close game' : '▶ Play'}</button>}
1025
+ {playable && !job && <ShareButton slug={slug} jobs={status?.jobs ?? []} onError={setError} />}
1026
+ <button type="button" class="ghost wide" title="The stage alone on the whole screen, for recording. Esc comes back" onClick={() => stageRef.current?.querySelector<HTMLElement>('.stage-body')?.requestFullscreen().catch((e) => setError(e.message))}>Film</button>
1027
+ <Bell />
1028
+ <Menu>
1029
+ {status && status.steps.length > 0 && <button type="button" class="mi go" title="The build bar again: every step, clickable for what it made. Free" onClick={() => onStage({ kind: 'build' })}>Build</button>}
1030
+ {status && status.steps.length > 0 && <button type="button" class="mi go" title="Every job this world went through; go back to before any of them. Free" onClick={() => setHistoryOpen(true)}>History</button>}
1031
+ {!job && status && !status.issue && <button type="button" class="mi go" title="The style every place and character is painted in, and changing it: repainted keeping where everything is. Shows the cost first" onClick={() => setStyleOpen(true)}>Style</button>}
1032
+ <hr />
1033
+ <button type="button" class="mi go" title="What the build, the agent and the game did, line by line" onClick={() => setLogOpen(true)}>Activity</button>
1034
+ <button type="button" class="mi go" title="Your notes on this diorama, open and done" onClick={() => setNotesOpen(true)}>Notes{openNotes.length ? ` · ${openNotes.length}` : ''}</button>
1035
+ {!job && status && !status.issue && <><hr /><Rebuild slug={slug} again={status.steps.length >= steps.length - 1} onError={setError} /></>}
1036
+ {!job && <><hr /><button type="button" class="mi danger go" title="Moves the world to worlds/.trash, so it can be brought back by moving its folder back" onClick={() => {
1037
+ void askSure({ title: `Delete ${status?.title ?? slug}?`, body: 'It leaves the Studio and goes to worlds/.trash/: moving its folder back from there brings it back.', go: 'Delete this world', danger: true, type: slug })
1038
+ .then((yes) => void (yes && call('world_delete', { slug }).then(() => { forgetWorld(slug); go('/'); }).catch((e) => setError(e.message))));
1039
+ }}>Delete this world…</button></>}
1040
+ </Menu>
1041
+ </div>
1042
+ {shownView.kind === 'place' && places.length > 1 && <BuildPlaces slug={slug} places={places} runs={placeRun} repairing={repairing} building={!!building} shown={stagedPlace?.key} verb="show it" onWatch={(place) => onStage({ kind: 'place', place })} />}
1043
+ <div class={`stage-body ${shownView.kind}`}>
1044
+ {shownView.kind === 'play' && <Play slug={slug} place={playing?.place} round={playing?.round ?? 0} full={playing?.full} onRestart={() => setPlaying({ ...playing, round: (playing?.round ?? 0) + 1 })} version={tick} onLine={playLine} onError={setError} />}
1045
+ {shownView.kind === 'build' && status && (
1046
+ <div class="build-on-stage">
1047
+ <Pipeline job={pipelineJob} all={steps} live={liveBuild} total={worldCalls} build={status.build} typical={status.typical ?? {}} onOpen={openPhase} />
1048
+ {places.length > 1 && <BuildPlaces slug={slug} places={places} runs={placeRun} repairing={repairing} building={!!building} watched={watchedPlace} shown={stagePlace?.key} onWatch={(place) => onStage({ kind: 'build', place })} onFollow={() => onStage({ kind: 'build' })} />}
1049
+ {stagePlace ? <Stage slug={slug} place={stagePlace} live={liveBuild} building={!!building} hold={hold} /> : <figure class="stage-live"><div class="frame"><div class="empty"><span>{status.pitch ?? named('Muse Spark is dreaming up the places…')}</span></div></div></figure>}
1050
+ </div>
1051
+ )}
1052
+ {shownView.kind === 'work' && status && (
1053
+ <div class="build-on-stage">
1054
+ <Pipeline job={pipelineJob} all={steps} live={liveBuild} total={worldCalls} build={status.build} typical={status.typical ?? {}} onOpen={openPhase} />
1055
+ {WORK_STEPS.includes(shownView.step ?? '')
1056
+ ? <StepWorkStage slug={slug} step={shownView.step!} running={liveBuild.running[shownView.step!] === 'run'}
1057
+ failed={liveBuild.running[shownView.step!] === 'fail' || (!job && failed?.args[0] === shownView.step) ? plainError(failed?.error ?? 'the step failed') : undefined} since={liveBuild.since[shownView.step!]} took={took(shownView.step!)} version={tick} />
1058
+ : <CastWorkStage slug={slug} step={shownView.step ?? 'cast'} work={castWork} running={liveBuild.running[shownView.step ?? ''] === 'run'}
1059
+ failed={liveBuild.running[shownView.step ?? ''] === 'fail' || (!job && failed?.args[0] === shownView.step) ? plainError(failed?.error ?? 'the step failed') : undefined} since={liveBuild.since[shownView.step ?? '']} took={took(shownView.step ?? 'cast')} version={tick}
1060
+ only={job && job.args[0] === shownView.step ? job.args[2] : undefined} onOpen={(key) => setByHand(`#cast:${key}`)} />}
1061
+ </div>
1062
+ )}
1063
+ {shownView.kind === 'place' && stagedPlace && <PlaceStage slug={slug} place={stagedPlace} version={tick} onOpen={(thing) => setByHand(`${stagedPlace.key}@${thing}`)} />}
1064
+ {shownView.kind === 'place' && !stagedPlace && <div class="mat"><p class="detail">{status?.pitch ?? named('Muse Spark is dreaming up the places…')}</p></div>}
1065
+ {shownView.kind === 'cast' && <CastStage slug={slug} version={tick} onOpen={(key) => setByHand(`#cast:${key}`)} />}
1066
+ {shownView.kind === 'place' && stagedPlace?.stages.painting && <button type="button" class="stage-tool" title="This place in detail: its things, floor, stand spots, lights and repaints, to fix by hand. Or just ask the agent" onClick={() => setByHand(stagedPlace.key)}><Icon name="pencil" /> Place details</button>}
1067
+ {shownView.kind === 'story' && (shownView.build && status ? <div class="build-on-stage">
1068
+ <Pipeline job={pipelineJob} all={steps} live={liveBuild} total={worldCalls} build={status.build} typical={status.typical ?? {}} onOpen={openPhase} />
1069
+ <div class="paper-panel"><StoryView slug={slug} version={tick} onError={setError} writing={liveBuild.running.story === 'run' ? { since: liveBuild.since.story, log: storyLog } : undefined} /></div>
1070
+ </div> : <div class="paper-panel"><StoryView slug={slug} version={tick} onError={setError} writing={liveBuild.running.story === 'run' ? { since: liveBuild.since.story, log: storyLog } : undefined} /></div>)}
1071
+ </div>
1072
+ </section>
1073
+ </main>
1074
+ {byHand && (
1075
+ <div class="drawer-back" onClick={(e) => e.target === e.currentTarget && setByHand(null)}>
1076
+ <section class="drawer by-hand" role="dialog" aria-label="By hand">
1077
+ <div class="row"><h2>{byHand.startsWith('#cast') ? 'Cast' : 'Places'}</h2><span class="grow" /><button type="button" class="ghost tiny" aria-label="Close" title="Close (Esc)" onClick={() => setByHand(null)}>✕</button></div>
1078
+ {!byHand.startsWith('#cast') && places.length > 1 && <BuildPlaces slug={slug} places={places} runs={placeRun} repairing={repairing} building={!!building} shown={byHand.split('@')[0]} verb="open its details" onWatch={(place) => { setByHand(place); if (shownView.kind === 'place') onStage({ kind: 'place', place }); }} />}
1079
+ {byHand.startsWith('#cast') ? <CastView slug={slug} version={tick} onError={setError} focus={byHand.split(':')[1]} /> : <PlaceEditor slug={slug} place={byHand.split('@')[0]} focus={byHand.split('@')[1]} version={tick} onError={setError} />}
1080
+ </section>
1081
+ </div>
1082
+ )}
1083
+ {styleOpen && (
1084
+ <div class="drawer-back" onClick={(e) => e.target === e.currentTarget && setStyleOpen(false)}>
1085
+ <section class="drawer style" role="dialog" aria-label="Style">
1086
+ <div class="row"><h2>Style</h2><span class="grow" /><button type="button" class="ghost tiny" aria-label="Close" title="Close (Esc)" onClick={() => setStyleOpen(false)}>✕</button></div>
1087
+ <StyleView slug={slug} places={places.map((p) => p.key)} version={tick} sample={places.find((p) => p.image)?.image ? fileUrl(slug, places.find((p) => p.image)!.image!.file, places.find((p) => p.image)!.image!.v) : undefined} onError={setError} onDone={() => setStyleOpen(false)} />
1088
+ </section>
1089
+ </div>
1090
+ )}
1091
+ {historyOpen && (
1092
+ <div class="drawer-back" onClick={(e) => e.target === e.currentTarget && setHistoryOpen(false)}>
1093
+ <section class="drawer history" role="dialog" aria-label="History">
1094
+ <div class="row"><h2>History</h2><span class="grow" /><button type="button" class="ghost tiny" aria-label="Close" title="Close (Esc)" onClick={() => setHistoryOpen(false)}>✕</button></div>
1095
+ {job ? <p class="note">{job.label} is running: going back waits until it is done.</p> : <HistoryView slug={slug} version={tick} onError={setError} onDone={() => setHistoryOpen(false)} />}
1096
+ </section>
1097
+ </div>
1098
+ )}
1099
+ {logOpen && (
1100
+ <div class="drawer-back" onClick={(e) => e.target === e.currentTarget && setLogOpen(false)}>
1101
+ <section class="drawer log" role="dialog" aria-label="Activity">
1102
+ <div class="row"><h2>Activity</h2><span class="grow" /><button type="button" class="ghost tiny" aria-label="Close" title="Close (Esc)" onClick={() => setLogOpen(false)}>✕</button></div>
1103
+ <div class="row"><span class="detail" title={status ? `this world: ${Object.entries(status.calls).map(([m, n]) => `${m} ${n}`).join(', ') || 'no calls yet'}` : undefined}>{calls} calls this session{budget ? ` · ${spend(budget.today)} today · ${spend(budget.total)}${budget.limitUsd !== null ? ` of $${budget.limitUsd}` : ''} total${budget.creditsLeft !== null ? ` · $${budget.creditsLeft.toFixed(2)} Meta credits left` : ''}` : ''}</span></div>
1104
+ <label class="mi switch" title="Also show every line the build steps print, not only what matters"><input type="checkbox" checked={allLines} onChange={(e) => setAllLines(e.currentTarget.checked)} /> <span>Every line the steps print</span></label>
1105
+ <Activity lines={lines} costs={costs} all={allLines} />
1106
+ </section>
1107
+ </div>
1108
+ )}
1109
+ {notesOpen && (
1110
+ <div class="drawer-back" onClick={(e) => e.target === e.currentTarget && setNotesOpen(false)}>
1111
+ <section class="drawer" role="dialog" aria-label="Notes">
1112
+ <div class="row"><h2>Your notes</h2><span class="grow" /><button type="button" class="ghost tiny" aria-label="Close notes" onClick={() => setNotesOpen(false)}>✕</button></div>
1113
+ <Notes slug={slug} notes={notes} onPlace={(p) => { onStage({ kind: 'place', place: p }); setNotesOpen(false); }} onError={setError} />
1114
+ </section>
1115
+ </div>
1116
+ )}
1117
+ </>
1118
+ );
1119
+ }
1120
+
1121
+ /** The game in a frame: the same page `diorama play` serves. What happens in it goes to Activity. */
1122
+ function Play({ slug, place, round, full, onRestart, version, onLine, onError }: { slug: string; place?: string; round: number; full?: boolean; onRestart: () => void; version: number; onLine: (t: string) => void; onError: (m: string) => void }) {
1123
+ const [fresh, setFresh] = useState(true);
1124
+ const [loadedAt, setLoadedAt] = useState(version);
1125
+ const loadedTime = useRef(0);
1126
+ const frame = useRef<HTMLIFrameElement>(null);
1127
+ // Started from the children's Let's play: straight onto the whole screen, still inside the click that asked for it.
1128
+ useEffect(() => void (full && frame.current?.requestFullscreen().then(() => frame.current?.focus()).catch(() => {})), []);
1129
+ // The refresh that follows a reload (an undo, a finished job) is not a change since the game started.
1130
+ useEffect(() => void (version !== loadedAt && (Date.now() - loadedTime.current < 3000 ? setLoadedAt(version) : setFresh(false))), [version]);
1131
+ // Gamepads: all steer one hero, take turns, or each steers its own hero (a party); kept for the next visit.
1132
+ const [pads, setPads] = useState<string>(() => {
1133
+ try {
1134
+ return localStorage.getItem('diorama.pads') ?? '';
1135
+ } catch {
1136
+ return '';
1137
+ }
1138
+ });
1139
+ // The gamepad choice shows only once two pads are plugged in: with one or none it means nothing.
1140
+ const [padCount, setPadCount] = useState(0);
1141
+ useEffect(() => {
1142
+ const count = () => setPadCount((navigator.getGamepads?.() ?? []).filter(Boolean).length);
1143
+ count();
1144
+ // Pads wake on a button press, often inside the game's frame where these events do not reach: look now and then too.
1145
+ const timer = setInterval(count, 2000);
1146
+ addEventListener('gamepadconnected', count);
1147
+ addEventListener('gamepaddisconnected', count);
1148
+ return () => (clearInterval(timer), removeEventListener('gamepadconnected', count), removeEventListener('gamepaddisconnected', count));
1149
+ }, []);
1150
+ const pickPads = (v: string) => {
1151
+ setPads(v);
1152
+ try {
1153
+ localStorage.setItem('diorama.pads', v);
1154
+ } catch {
1155
+ // A private window keeps it for this visit only.
1156
+ }
1157
+ };
1158
+ const query = new URLSearchParams({ ...(place && { place }), ...(pads && { pads }) }).toString();
1159
+ const src = `/play/${slug}/${query ? `?${query}` : ''}`;
1160
+ const hook = (e: Event) => {
1161
+ setFresh(true);
1162
+ setLoadedAt(version);
1163
+ loadedTime.current = Date.now();
1164
+ const game = ((e.currentTarget as HTMLIFrameElement).contentWindow as unknown as { diorama?: { on(fn: (ev: string, d: Record<string, unknown>) => void): void } } | null)?.diorama;
1165
+ game?.on((ev, d) => {
1166
+ const say: Record<string, string> = {
1167
+ enter: `entered ${d.place}`, exit: `left ${d.from} for ${d.to}`, use: `${d.verb ?? 'used'} ${d.thing}`, say: `"${String(d.text ?? '').slice(0, 80)}"`, ending: `reached the ending ${d.ending}`,
1168
+ };
1169
+ if (say[ev]) onLine(`play: ${say[ev]}`);
1170
+ });
1171
+ };
1172
+ return (
1173
+ <section class="play">
1174
+ <div class="row">
1175
+ <strong>Playing{place ? ` from ${place}` : ''}</strong>
1176
+ <span class="detail">click to walk or use, right click to look{padCount ? ', or use the gamepad' : ''}</span>
1177
+ <span class="grow" />
1178
+ {padCount > 1 && <label class="detail" title="With several gamepads: all steer the one hero, they take turns, or each pad has its own hero">
1179
+ Gamepads{' '}
1180
+ <select value={pads} onChange={(e) => pickPads(e.currentTarget.value)}>
1181
+ <option value="">one hero</option>
1182
+ <option value="turns">take turns</option>
1183
+ <option value="party">a hero each</option>
1184
+ </select>
1185
+ </label>}
1186
+ <button type="button" class="ghost small" title="Start the game over from the beginning. Free" onClick={onRestart}>Restart</button>
1187
+ {/* For a big screen: the game letterboxes to it, keyboard and gamepads keep working; Esc comes back. */}
1188
+ <button type="button" class="ghost small" title="Play on the whole screen (Esc to come back)" onClick={() => frame.current?.requestFullscreen().then(() => frame.current?.focus()).catch((err) => onError(err.message))}>Full screen</button>
1189
+ <button type="button" class="ghost small" title="The bot plays every route to an ending in a browser and says where it got stuck. A minute or two, no model calls" onClick={() => call('playthrough', { slug }).catch((err) => onError(err.message))}>Bot playthrough</button>
1190
+ </div>
1191
+ {!fresh && (
1192
+ <div class="banner row" role="status">
1193
+ <span>The world changed since this game started.</span>
1194
+ <span class="grow" />
1195
+ <button type="button" class="primary small" title="Start the game again with the latest changes" onClick={onRestart}>Reload the game</button>
1196
+ </div>
1197
+ )}
1198
+ {/* The game letterboxes to the room it is given, never wider or taller: no page scroll while playing. */}
1199
+ <div class="screen">
1200
+ <iframe ref={frame} key={`${src}#${round}`} src={src} title={`${slug}, playable`} onLoad={hook} allow="autoplay; fullscreen; gamepad" allowFullScreen />
1201
+ </div>
1202
+ </section>
1203
+ );
1204
+ }
1205
+
1206
+ const spend = (s: Spend) => (s.priced && Object.keys(s.calls).length > 0 ? `$${s.usd.toFixed(2)}` : `${Object.values(s.calls).reduce((a, b) => a + b, 0)} calls${s.usd ? ` ($${s.usd.toFixed(2)}+)` : ''}`);
1207
+
1208
+ /** The last job failed: where, why, its last lines, and what to do next. */
1209
+ function Failed({ slug, job, noAgent, onError, onPlace }: { slug: string; job: Job; noAgent: string | null; onError: (m: string) => void; onPlace: (p: string) => void }) {
1210
+ const [more, setMore] = useState(false);
1211
+ // The output is for when the reason above is not enough: hidden at first when it names the places, and never with this machine's folders.
1212
+ const lines = job.tail.filter((l) => l.trim()).map((l) => l.replace(/\/(?:Users|home|tmp)\/\S*?\/(worlds\/)/g, '$1').replace(/\/(?:Users|home)\/[^/\s]+/g, '~'));
1213
+ const listed = !!job.unplayable?.length;
1214
+ const step = job.step;
1215
+ const retry = () => call('job_retry', { id: job.id }).catch((e) => onError(e.message));
1216
+ const ask = () => askAgent(slug, `The job "${job.label}" failed${step ? ` at the ${step} step` : ''}: ${job.error ?? 'see its output'}. Find out why with world_status and place_status, fix it only with the Studio fix tools (never by editing files), and tell me what you did.`, `Fix the failed ${job.label.split(' ')[0] === 'build' ? 'build' : `“${job.label}”`}${step ? ` at ${step}` : ''}: ${plainError(job.error)}`).catch((e) => onError(e.message));
1217
+ return (
1218
+ <section class="failed" role="alert">
1219
+ <div class="row"><strong>{job.label} failed{step ? ` at ${step}` : ''}</strong><span class="detail">by {job.by}</span></div>
1220
+ {job.unplayable?.length ? job.unplayable.map((u) => (
1221
+ <div class="row"><button type="button" class="ghost small" title="Open this place to look at it and fix it" onClick={() => onPlace(u.place)}>{u.place}</button><span class="error">{u.errors.join(' · ') || 'not playable: see verify.json'}</span></div>
1222
+ )) : job.error && <p class="error">{job.error}</p>}
1223
+ {noAgent && listed && <p class="detail">The agent would repair these, but {noAgent} is not installed on this computer. <a href="/setup" onClick={(e) => (e.preventDefault(), go('/setup'))}>Setup</a> says how to add it.</p>}
1224
+ {lines.length > 0 && (more || !listed) && <pre>{(more ? lines : lines.slice(-6)).join('\n')}</pre>}
1225
+ <div class="row">
1226
+ <button type="button" class="primary small" title="Run the job again from the step it failed on. Done steps are free" onClick={retry}>Try again</button>
1227
+ <button type="button" class="ghost small" disabled={!!noAgent} title={noAgent ? `${noAgent} is not installed` : 'The agent finds out why and fixes it with the Studio tools. It uses model calls as it works'} onClick={ask}>Ask the agent</button>
1228
+ <button type="button" class="ghost small" title="Put the world back as it was before this job. Free" onClick={() => call('history_undo', { slug }).catch((e) => onError(e.message))}>Undo it</button>
1229
+ <span class="grow" />
1230
+ {(lines.length > 6 || (listed && lines.length > 0)) && <button type="button" class="ghost small" title={more ? 'Show only the last lines' : 'Every line the job printed'} onClick={() => setMore(!more)}>{more ? 'Less' : `Its output (${lines.length} lines)`}</button>}
1231
+ </div>
1232
+ </section>
1233
+ );
1234
+ }
1235
+
1236
+ /** The person's notes on this world: open ones wait for a fix, done ones say what was done. */
1237
+ function Notes({ slug, notes, onPlace, onError }: { slug: string; notes: Note[]; onPlace: (p: string) => void; onError: (m: string) => void }) {
1238
+ const [text, setText] = useState('');
1239
+ const open = notes.filter((n) => n.state === 'open');
1240
+ const done = notes.filter((n) => n.state === 'done').reverse();
1241
+ const add = (e: Event) => {
1242
+ e.preventDefault();
1243
+ call('feedback_add', { slug, text: text.trim() }).then(() => setText('')).catch((err) => onError(err.message));
1244
+ };
1245
+ return (
1246
+ <section class="notes">
1247
+ <div class="row">
1248
+ <h2>Notes{open.length ? ` · ${open.length} open` : ''}</h2>
1249
+ <span class="grow" />
1250
+ {open.length > 0 && <button type="button" class="primary small" title="The agent fixes each open note and closes it with what it did. It uses model calls as it works" onClick={() => askAgent(slug, `Go through my ${open.length} open note${open.length > 1 ? 's' : ''} (feedback_pending): fix each one and close it with feedback_done.`, `Fix my ${open.length} open note${open.length > 1 ? 's' : ''}`).catch((e) => onError(e.message))}>Hand {open.length > 1 ? 'them' : 'it'} to the agent</button>}
1251
+ </div>
1252
+ <ul>
1253
+ {open.map((n) => (
1254
+ <li class="open">
1255
+ {n.place && <button type="button" class="ghost small" title="Open the place this note is on" onClick={() => onPlace(n.place!)}>{n.place}{n.point ? ` @${n.point.join(',')}` : ''}</button>}
1256
+ <span>{n.text}</span>
1257
+ <span class="grow" />
1258
+ <button type="button" class="ghost small" title="Close the note: you fixed it yourself" onClick={() => call('feedback_done', { slug, id: n.id, reply: 'done by hand' }).catch((e) => onError(e.message))}>Done</button>
1259
+ </li>
1260
+ ))}
1261
+ {done.map((n) => (
1262
+ <li class="done">
1263
+ <span>{n.text}</span>
1264
+ <span class="detail">→ {n.reply}</span>
1265
+ <span class="grow" />
1266
+ <button type="button" class="ghost small" title="Open the note again: it is not fixed" onClick={() => call('feedback_reopen', { slug, id: n.id }).catch((e) => onError(e.message))}>Reopen</button>
1267
+ </li>
1268
+ ))}
1269
+ </ul>
1270
+ <form class="row" onSubmit={add}>
1271
+ <input class="grow" placeholder="A note on the whole world (or pick a place and use Note on its picture)" value={text} onInput={(e) => setText(e.currentTarget.value)} />
1272
+ <button type="submit" class="ghost small" disabled={text.trim().length < 2} title="Keep the note open until it is fixed. Free">Add</button>
1273
+ </form>
1274
+ </section>
1275
+ );
1276
+ }
1277
+
1278
+ function NoToken() {
1279
+ return (
1280
+ <main class="narrow">
1281
+ <h1>Open Studio from your terminal</h1>
1282
+ <p class="lead">This page only opens through the link <code>diorama studio</code> prints, so no other site or program can drive it.</p>
1283
+ <pre>diorama studio</pre>
1284
+ </main>
1285
+ );
1286
+ }
1287
+
1288
+ function App() {
1289
+ const [ready, setReady] = useState<'wait' | 'ok' | string>('wait');
1290
+ useEffect(() => {
1291
+ if (!token) return;
1292
+ Promise.all([openSession(), listActions()])
1293
+ .then(([, list]) => {
1294
+ steps = list.find((a) => a.name === 'run_step')?.input.properties?.step?.enum ?? [];
1295
+ quiet = new Set(list.filter((a) => a.reads).map((a) => a.name));
1296
+ setReady('ok');
1297
+ // The model labels say who works now, from the first screen on.
1298
+ void call<Models>('models_get').then(showModels).catch(() => {});
1299
+ })
1300
+ .catch((e) => setReady(e.message));
1301
+ // The agent can move the view to another world.
1302
+ return follow(null, (e) => {
1303
+ if (e.type !== 'ui.focus' || e.by !== 'agent' || !followAgent.value || !e.slug) return;
1304
+ const here = /^\/w\/([a-z][a-z0-9-]*)/.exec(route.value)?.[1];
1305
+ if (here && here !== e.slug) movedFrom.value = here;
1306
+ go(`/w/${e.slug}`);
1307
+ });
1308
+ }, []);
1309
+ if (!token) return <NoToken />;
1310
+ if (ready === 'wait') return <main class="narrow"><p class="lead">Opening…</p></main>;
1311
+ if (ready !== 'ok') return <main class="narrow"><h1>Studio</h1><p class="error">{ready}</p></main>;
1312
+ return <Shell />;
1313
+ }
1314
+
1315
+ /** The atelier: the worlds rail on the left, then the page (the prompt, a world, or Setup). */
1316
+ function Shell() {
1317
+ useAtelierFeed();
1318
+ const path = route.value;
1319
+ const world = /^\/w\/([a-z][a-z0-9-]*)/.exec(path)?.[1];
1320
+ return (
1321
+ <div class="atelier">
1322
+ <Rail />
1323
+ <div class="atelier-page">{path === '/setup' ? <Setup /> : world ? <Workshop key={world} slug={world} /> : <Start />}</div>
1324
+ </div>
1325
+ );
1326
+ }
1327
+
1328
+ render(<><App /><Tips /><Lightbox /><Sure /></>, document.getElementById('app')!);