@gtanczyk/diorama 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +157 -2
  3. package/app/bin/diorama.mjs +13 -0
  4. package/app/src/actions/budget.ts +160 -0
  5. package/app/src/actions/build.ts +158 -0
  6. package/app/src/actions/cast.ts +267 -0
  7. package/app/src/actions/depth.ts +95 -0
  8. package/app/src/actions/extend.ts +36 -0
  9. package/app/src/actions/faces.ts +160 -0
  10. package/app/src/actions/feedback.ts +87 -0
  11. package/app/src/actions/fix.ts +382 -0
  12. package/app/src/actions/history.ts +93 -0
  13. package/app/src/actions/idea-preview.ts +65 -0
  14. package/app/src/actions/idea-review.ts +102 -0
  15. package/app/src/actions/idea-sketch.ts +172 -0
  16. package/app/src/actions/index.ts +32 -0
  17. package/app/src/actions/items.ts +94 -0
  18. package/app/src/actions/kids.ts +215 -0
  19. package/app/src/actions/language.ts +52 -0
  20. package/app/src/actions/models.ts +64 -0
  21. package/app/src/actions/motion.ts +119 -0
  22. package/app/src/actions/pictures.ts +49 -0
  23. package/app/src/actions/reactions.ts +36 -0
  24. package/app/src/actions/registry.ts +101 -0
  25. package/app/src/actions/share.ts +32 -0
  26. package/app/src/actions/sound.ts +73 -0
  27. package/app/src/actions/start.ts +154 -0
  28. package/app/src/actions/step-work.ts +150 -0
  29. package/app/src/actions/story.ts +101 -0
  30. package/app/src/actions/studio.ts +133 -0
  31. package/app/src/actions/style.ts +72 -0
  32. package/app/src/actions/video.ts +46 -0
  33. package/app/src/actions/view.ts +109 -0
  34. package/app/src/actions/weather.ts +38 -0
  35. package/app/src/actions/world.ts +290 -0
  36. package/app/src/commands/action.ts +39 -0
  37. package/app/src/commands/backdrop.ts +188 -0
  38. package/app/src/commands/browsers.ts +21 -0
  39. package/app/src/commands/build.ts +503 -0
  40. package/app/src/commands/cast.ts +161 -0
  41. package/app/src/commands/clip.ts +431 -0
  42. package/app/src/commands/derive.ts +167 -0
  43. package/app/src/commands/doctor.ts +74 -0
  44. package/app/src/commands/explore.ts +123 -0
  45. package/app/src/commands/export.ts +158 -0
  46. package/app/src/commands/extend.ts +75 -0
  47. package/app/src/commands/faces.ts +80 -0
  48. package/app/src/commands/icons.ts +69 -0
  49. package/app/src/commands/index.ts +37 -0
  50. package/app/src/commands/language.ts +87 -0
  51. package/app/src/commands/layout.ts +31 -0
  52. package/app/src/commands/masks.ts +117 -0
  53. package/app/src/commands/mcp.ts +52 -0
  54. package/app/src/commands/model.ts +284 -0
  55. package/app/src/commands/paint.ts +182 -0
  56. package/app/src/commands/plates.ts +97 -0
  57. package/app/src/commands/play.ts +195 -0
  58. package/app/src/commands/playthrough.ts +111 -0
  59. package/app/src/commands/publish.ts +78 -0
  60. package/app/src/commands/reactions.ts +40 -0
  61. package/app/src/commands/reel.ts +46 -0
  62. package/app/src/commands/setup.ts +85 -0
  63. package/app/src/commands/sound.ts +100 -0
  64. package/app/src/commands/sprites.ts +232 -0
  65. package/app/src/commands/story.ts +37 -0
  66. package/app/src/commands/studio.ts +65 -0
  67. package/app/src/commands/style.ts +87 -0
  68. package/app/src/commands/verify.ts +45 -0
  69. package/app/src/commands/voice-retry.ts +14 -0
  70. package/app/src/commands/voices.ts +145 -0
  71. package/app/src/commands/weather.ts +52 -0
  72. package/app/src/commands/world.ts +104 -0
  73. package/app/src/film/clip.ts +386 -0
  74. package/app/src/film/reel.ts +126 -0
  75. package/app/src/film/screencast.ts +123 -0
  76. package/app/src/image/mask.ts +29 -0
  77. package/app/src/image/overlay.ts +38 -0
  78. package/app/src/image/raster.ts +224 -0
  79. package/app/src/main.ts +28 -0
  80. package/app/src/meta/choice.ts +27 -0
  81. package/app/src/meta/http.ts +99 -0
  82. package/app/src/meta/memo.ts +105 -0
  83. package/app/src/meta/muse.ts +70 -0
  84. package/app/src/meta/openrouter.ts +93 -0
  85. package/app/src/meta/prices.ts +25 -0
  86. package/app/src/meta/sam.ts +83 -0
  87. package/app/src/meta/schema.ts +63 -0
  88. package/app/src/meta/spark.ts +220 -0
  89. package/app/src/meta/step.ts +14 -0
  90. package/app/src/meta/voice-file.ts +78 -0
  91. package/app/src/meta/voice.ts +103 -0
  92. package/app/src/model/animation.ts +38 -0
  93. package/app/src/model/blender/relax.py +284 -0
  94. package/app/src/model/blender.ts +115 -0
  95. package/app/src/model/build.ts +50 -0
  96. package/app/src/model/face.ts +21 -0
  97. package/app/src/model/fit.ts +181 -0
  98. package/app/src/model/glb.ts +241 -0
  99. package/app/src/model/plan.ts +151 -0
  100. package/app/src/model/quality.ts +48 -0
  101. package/app/src/model/space.ts +168 -0
  102. package/app/src/model/sprites.ts +293 -0
  103. package/app/src/model/views.ts +163 -0
  104. package/app/src/play/creature.ts +43 -0
  105. package/app/src/play/depth-occlusion.ts +150 -0
  106. package/app/src/play/exit-outline.ts +48 -0
  107. package/app/src/play/face3d.ts +34 -0
  108. package/app/src/play/focus.ts +78 -0
  109. package/app/src/play/geometry.ts +272 -0
  110. package/app/src/play/gestures.ts +23 -0
  111. package/app/src/play/glow.ts +187 -0
  112. package/app/src/play/lipsync.ts +69 -0
  113. package/app/src/play/living.ts +83 -0
  114. package/app/src/play/outline-path.ts +130 -0
  115. package/app/src/play/parallax.ts +200 -0
  116. package/app/src/play/path.ts +262 -0
  117. package/app/src/play/preview.ts +71 -0
  118. package/app/src/play/reactions.ts +92 -0
  119. package/app/src/play/rig.ts +172 -0
  120. package/app/src/play/shadow.ts +253 -0
  121. package/app/src/play/sound.ts +51 -0
  122. package/app/src/play/weather.ts +58 -0
  123. package/app/src/qa/explore.ts +136 -0
  124. package/app/src/qa/probe.ts +139 -0
  125. package/app/src/qa/route.ts +118 -0
  126. package/app/src/registry.ts +23 -0
  127. package/app/src/scene/animation-source.ts +36 -0
  128. package/app/src/scene/assets.ts +37 -0
  129. package/app/src/scene/autofix.ts +133 -0
  130. package/app/src/scene/backdrop.ts +326 -0
  131. package/app/src/scene/blockin.ts +189 -0
  132. package/app/src/scene/brief.ts +74 -0
  133. package/app/src/scene/carry.ts +177 -0
  134. package/app/src/scene/cast-fixes.ts +33 -0
  135. package/app/src/scene/cast.ts +199 -0
  136. package/app/src/scene/corrections.ts +164 -0
  137. package/app/src/scene/critic.ts +169 -0
  138. package/app/src/scene/depthmap.ts +149 -0
  139. package/app/src/scene/derive.ts +475 -0
  140. package/app/src/scene/diagnose.ts +132 -0
  141. package/app/src/scene/face-calibration.ts +65 -0
  142. package/app/src/scene/face-registration.ts +21 -0
  143. package/app/src/scene/face-work.ts +71 -0
  144. package/app/src/scene/faces-data.ts +48 -0
  145. package/app/src/scene/faces-settings.ts +20 -0
  146. package/app/src/scene/faces.ts +435 -0
  147. package/app/src/scene/glows.ts +153 -0
  148. package/app/src/scene/held.ts +109 -0
  149. package/app/src/scene/icons.ts +185 -0
  150. package/app/src/scene/layers.ts +822 -0
  151. package/app/src/scene/layout.ts +216 -0
  152. package/app/src/scene/locate.ts +118 -0
  153. package/app/src/scene/maskcheck.ts +43 -0
  154. package/app/src/scene/masks.ts +392 -0
  155. package/app/src/scene/occluders.ts +123 -0
  156. package/app/src/scene/occlusion-check.ts +138 -0
  157. package/app/src/scene/paint-layout.ts +135 -0
  158. package/app/src/scene/paint.ts +81 -0
  159. package/app/src/scene/photo.ts +123 -0
  160. package/app/src/scene/plates.ts +256 -0
  161. package/app/src/scene/playbook.ts +121 -0
  162. package/app/src/scene/preview.ts +23 -0
  163. package/app/src/scene/reactions.ts +118 -0
  164. package/app/src/scene/restyle.ts +53 -0
  165. package/app/src/scene/rig-check.ts +68 -0
  166. package/app/src/scene/rig.ts +379 -0
  167. package/app/src/scene/scale.ts +148 -0
  168. package/app/src/scene/shadows.ts +215 -0
  169. package/app/src/scene/stairs.ts +183 -0
  170. package/app/src/scene/status.ts +32 -0
  171. package/app/src/scene/verify.ts +156 -0
  172. package/app/src/scene/walk.ts +326 -0
  173. package/app/src/scene/warp.ts +419 -0
  174. package/app/src/scene/weather.ts +118 -0
  175. package/app/src/scene/words.ts +65 -0
  176. package/app/src/secrets.ts +219 -0
  177. package/app/src/setup/checks.ts +110 -0
  178. package/app/src/sound/cost.ts +74 -0
  179. package/app/src/sound/data.ts +89 -0
  180. package/app/src/sound/fixes.ts +23 -0
  181. package/app/src/sound/issues.ts +21 -0
  182. package/app/src/sound/language.ts +11 -0
  183. package/app/src/sound/lipsync-make.ts +98 -0
  184. package/app/src/sound/lipsync.ts +24 -0
  185. package/app/src/sound/make.ts +229 -0
  186. package/app/src/sound/musicgen.py +20 -0
  187. package/app/src/sound/pcm.ts +280 -0
  188. package/app/src/sound/plan.ts +154 -0
  189. package/app/src/sound/retry.ts +43 -0
  190. package/app/src/sound/settings.ts +28 -0
  191. package/app/src/sound/sources.ts +81 -0
  192. package/app/src/sound/spoken.ts +70 -0
  193. package/app/src/sound/voices.ts +579 -0
  194. package/app/src/speech/transcribe.ts +38 -0
  195. package/app/src/speech/types.ts +18 -0
  196. package/app/src/story/check.ts +230 -0
  197. package/app/src/story/design.ts +108 -0
  198. package/app/src/story/engine.ts +233 -0
  199. package/app/src/story/fixes.ts +54 -0
  200. package/app/src/story/npc-guidance.ts +7 -0
  201. package/app/src/story/phrases.ts +4 -0
  202. package/app/src/story/schema.ts +92 -0
  203. package/app/src/studio/agent.ts +555 -0
  204. package/app/src/studio/discovery.ts +28 -0
  205. package/app/src/studio/events.ts +48 -0
  206. package/app/src/studio/history.ts +124 -0
  207. package/app/src/studio/jobs.ts +291 -0
  208. package/app/src/studio/locks.ts +33 -0
  209. package/app/src/studio/mcp.ts +86 -0
  210. package/app/src/studio/operation.ts +18 -0
  211. package/app/src/studio/repair.ts +31 -0
  212. package/app/src/studio/server.ts +423 -0
  213. package/app/src/studio/trouble.ts +48 -0
  214. package/app/src/world/design.ts +72 -0
  215. package/app/src/world/drawings.ts +198 -0
  216. package/app/src/world/extend-stage.ts +109 -0
  217. package/app/src/world/extend.ts +123 -0
  218. package/app/src/world/extension-progress.ts +10 -0
  219. package/app/src/world/files.ts +62 -0
  220. package/app/src/world/idea-review.ts +32 -0
  221. package/app/src/world/language.ts +116 -0
  222. package/app/src/world/photo-limits.ts +2 -0
  223. package/app/src/world/pool.ts +41 -0
  224. package/app/src/world/schedule.ts +64 -0
  225. package/app/src/world/schema.ts +74 -0
  226. package/app/src/world/sketch.ts +81 -0
  227. package/app/src/world/style-plan.ts +90 -0
  228. package/app/src/world/style-reference.ts +16 -0
  229. package/app/src/world/styled.ts +51 -0
  230. package/app/src/world/told.ts +105 -0
  231. package/app/src/world/video-limits.ts +3 -0
  232. package/app/src/world/video.ts +98 -0
  233. package/app/studio/activity.tsx +124 -0
  234. package/app/studio/agent.tsx +384 -0
  235. package/app/studio/api.ts +111 -0
  236. package/app/studio/atelier.tsx +234 -0
  237. package/app/studio/audio-missing.tsx +25 -0
  238. package/app/studio/audio-setup.tsx +33 -0
  239. package/app/studio/blender-setup.tsx +30 -0
  240. package/app/studio/build.tsx +679 -0
  241. package/app/studio/camera.tsx +59 -0
  242. package/app/studio/cast-work.tsx +207 -0
  243. package/app/studio/cast.tsx +178 -0
  244. package/app/studio/concept-edit.tsx +47 -0
  245. package/app/studio/creator-idea.tsx +46 -0
  246. package/app/studio/custom-look.tsx +36 -0
  247. package/app/studio/faces-setup.tsx +28 -0
  248. package/app/studio/favicon.svg +11 -0
  249. package/app/studio/figure.tsx +314 -0
  250. package/app/studio/fonts/OFL.txt +93 -0
  251. package/app/studio/fonts/fonts.css +7 -0
  252. package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
  253. package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
  254. package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
  255. package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
  256. package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
  257. package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
  258. package/app/studio/icons.tsx +42 -0
  259. package/app/studio/idea-card.tsx +42 -0
  260. package/app/studio/idea-review.tsx +181 -0
  261. package/app/studio/idea-storyboard.tsx +134 -0
  262. package/app/studio/index.html +15 -0
  263. package/app/studio/job-words.ts +33 -0
  264. package/app/studio/lightbox.tsx +49 -0
  265. package/app/studio/main.tsx +1329 -0
  266. package/app/studio/nav.ts +14 -0
  267. package/app/studio/place.tsx +264 -0
  268. package/app/studio/review-sketch.tsx +47 -0
  269. package/app/studio/say-it.tsx +49 -0
  270. package/app/studio/step-work.tsx +134 -0
  271. package/app/studio/story.tsx +394 -0
  272. package/app/studio/style-menu.tsx +81 -0
  273. package/app/studio/style.css +1390 -0
  274. package/app/studio/sure.tsx +54 -0
  275. package/app/studio/tip.tsx +74 -0
  276. package/app/studio/tsconfig.json +5 -0
  277. package/app/studio/video.tsx +73 -0
  278. package/app/studio/world.tsx +188 -0
  279. package/app/tsconfig.json +13 -0
  280. package/app/web/audio.ts +315 -0
  281. package/app/web/face.ts +131 -0
  282. package/app/web/face3d.ts +35 -0
  283. package/app/web/glows.ts +387 -0
  284. package/app/web/hero3d.ts +728 -0
  285. package/app/web/index.html +118 -0
  286. package/app/web/living.ts +404 -0
  287. package/app/web/main.ts +3769 -0
  288. package/app/web/pad.ts +174 -0
  289. package/app/web/reactions.ts +79 -0
  290. package/app/web/sheet.ts +36 -0
  291. package/app/web/synth.ts +140 -0
  292. package/app/web/tsconfig.json +5 -0
  293. package/app/web/ui.ts +263 -0
  294. package/app/web/weather.ts +306 -0
  295. package/app/web/words.ts +12 -0
  296. package/docs/characters.md +58 -0
  297. package/docs/npc-motion.md +69 -0
  298. package/docs/scenes.md +75 -0
  299. package/docs/setup.md +60 -0
  300. package/docs/sound.md +23 -0
  301. package/docs/studio.md +62 -0
  302. package/package.json +70 -4
@@ -0,0 +1,59 @@
1
+ // Pictures for the agent: a photo or a drawing shrunk to travel light, and the webcam held up to the paper.
2
+ import { useEffect, useRef, useState } from 'preact/hooks';
3
+
4
+ /** A photo shrunk to 1600 px as JPEG, so a phone picture travels light; what the browser cannot read goes as it is. */
5
+ export async function shrink(file: Blob): Promise<string> {
6
+ try {
7
+ const bitmap = await createImageBitmap(file);
8
+ const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
9
+ const canvas = document.createElement('canvas');
10
+ canvas.width = Math.round(bitmap.width * scale);
11
+ canvas.height = Math.round(bitmap.height * scale);
12
+ canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
13
+ return canvas.toDataURL('image/jpeg', 0.88);
14
+ } catch {
15
+ return base64(file);
16
+ }
17
+ }
18
+
19
+ export function base64(blob: Blob): Promise<string> {
20
+ return new Promise((resolve, reject) => {
21
+ const reader = new FileReader();
22
+ reader.onload = () => resolve(String(reader.result));
23
+ reader.onerror = () => reject(reader.error);
24
+ reader.readAsDataURL(blob);
25
+ });
26
+ }
27
+
28
+ /** The webcam held up to a drawing: a live preview and a button that keeps one frame. */
29
+ export function Camera({ onSnap, onClose, tip = 'Hold the drawing up to the camera, flat and filling the picture.', words = { snap: 'Take the photo', done: 'Done', none: 'No camera' } }: { onSnap: (jpeg: string) => void; onClose: () => void; tip?: string; words?: { snap: string; done: string; none: string } }) {
30
+ const video = useRef<HTMLVideoElement>(null);
31
+ const [error, setError] = useState('');
32
+ useEffect(() => {
33
+ let stream: MediaStream | null = null;
34
+ navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1920 }, height: { ideal: 1080 } } }).then((s) => {
35
+ stream = s;
36
+ if (video.current) video.current.srcObject = s;
37
+ }).catch((e) => setError(`${words.none}: ${e.message}`));
38
+ return () => stream?.getTracks().forEach((t) => t.stop());
39
+ }, []);
40
+ const snap = () => {
41
+ const v = video.current;
42
+ if (!v || !v.videoWidth) return;
43
+ const canvas = document.createElement('canvas');
44
+ canvas.width = v.videoWidth;
45
+ canvas.height = v.videoHeight;
46
+ canvas.getContext('2d')!.drawImage(v, 0, 0);
47
+ onSnap(canvas.toDataURL('image/jpeg', 0.9));
48
+ };
49
+ return (
50
+ <div class="camera">
51
+ {error ? <p class="error">{error}</p> : <video ref={video} autoPlay playsInline muted />}
52
+ <div class="row">
53
+ <button type="button" class="primary" onClick={snap} disabled={!!error}>{words.snap}</button>
54
+ <button type="button" class="ghost" onClick={onClose}>{words.done}</button>
55
+ <span class="detail">{tip}</span>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,207 @@
1
+ // A cast step at work, live: who is being painted, modelled or drawn as sprites, each picture as it lands, the
2
+ // stage it is at (TRELLIS, skeleton) and the time left; when one is done, it moves right there.
3
+ import { useEffect, useState } from 'preact/hooks';
4
+ import { call, fileUrl } from './api.ts';
5
+ import { FigurePreview, MouthShapes, Sides } from './figure.tsx';
6
+ import { nice } from './world.tsx';
7
+ import { named, nowBy } from './build.tsx';
8
+ import { Icon } from './icons.tsx';
9
+
10
+ /** Per character: the files the running job wrote (world path → when), what the build last said about them, and
11
+ * when each cast step first touched them (a build paints the whole cast long before it models the last one). */
12
+ export type CastWork = Record<string, { files: Record<string, number>; said: string; at: number; began: Record<string, number> }>;
13
+ export const CAST_STEPS = ['cast', 'model', 'sprites', 'faces'];
14
+
15
+ const FILE = /^cast\/([a-z0-9][\w-]*)(-painting\.png|\.png|\/model\/[\w-]+\.(?:png|json)|\/sprites\/[\w-]+\.(?:png|json)|\/face-[\w-]+\.(?:png|json)|\/face-work\/[\w-]+\.png)$/;
16
+ /** A file the cast steps write, as [character, step]. */
17
+ export function castFile(file: string): [string, string] | null {
18
+ const m = FILE.exec(file);
19
+ if (!m) return null;
20
+ return [m[1], m[2].startsWith('/model/') ? 'model' : m[2].startsWith('/sprites/') ? 'sprites' : m[2].startsWith('/face') ? 'faces' : 'cast'];
21
+ }
22
+ /** A build line about one character ("hero: TRELLIS builds the body", "hero walk right: take 2 …"), as [character, words]. */
23
+ export function castLine(line: string): [string, string] | null {
24
+ const m = /^([a-z0-9][\w-]*)((?: (?:walk|talk) [\w-]+(?: \[[^\]]*\])?)?): (.+)$/.exec(line.trim());
25
+ return m ? [m[1], `${m[2].trim() ? `${m[2].trim()}: ` : ''}${m[3]}`] : null;
26
+ }
27
+
28
+ /** The cast step a build line belongs to ("<key>: TRELLIS builds the body" is the model step). */
29
+ export function lineStep(words: string): string {
30
+ return /4 sides|TRELLIS|rigging|3D model|auto-rig/.test(words) ? 'model' : /face (?:prepared|unchanged)|mouth|facial|lips/.test(words) ? 'faces' : /^(?:walk|talk) |sprite/.test(words) ? 'sprites' : 'cast';
31
+ }
32
+
33
+ /** One character's work with a build line or a landed file added, the step's start kept from the first touch. */
34
+ export function castTouched(w: CastWork[string] | undefined, step: string, change: { said?: string; file?: string }, now = Date.now()): CastWork[string] {
35
+ return {
36
+ said: change.said ?? w?.said ?? '',
37
+ files: change.file ? { ...w?.files, [change.file]: now } : w?.files ?? {},
38
+ began: w?.began[step] ? w.began : { ...w?.began, [step]: now },
39
+ at: now,
40
+ };
41
+ }
42
+
43
+ const VIEWS = ['front', 'back', 'left', 'right'];
44
+ const STAGES = [
45
+ { word: '4 sides', by: 'Muse Image', tip: 'Muse Image paints the character from the front, back and both sides', s: 40 },
46
+ { word: 'Body', by: 'TRELLIS', tip: 'TRELLIS builds a 3D body from the four sides (Hugging Face GPU)', s: 30 },
47
+ { word: 'Skeleton', by: 'auto-rig', tip: 'A skeleton is fitted into the body so it can walk', s: 25 },
48
+ ];
49
+ const WHAT: Record<string, string> = { cast: 'Painting the cast', model: 'Building 3D models', sprites: 'Painting sprites', faces: 'Preparing talking faces' };
50
+ /** The newest face picture the faces step wrote for one character: the mouth shapes so far. */
51
+ const newestFace = (key: string, w: CastWork[string]) => Object.entries(w.files).filter(([f]) => f.startsWith(`cast/${key}/face`) && f.endsWith('.png') && !f.endsWith('-reference.png')).sort((a, b) => b[1] - a[1])[0];
52
+
53
+ type Who = { key: string; role: string; looksNow: string; figure: { file: string; v: number } | null; painting: { file: string; v: number } | null; stamps: { rig: number | null; model: number | null; sprites: number | null }; body: { upright: boolean } | null;
54
+ face?: { ready: boolean; attempt?: { state?: string; reason?: string; at?: string; skipped?: string[] } | null } };
55
+
56
+ const clock = (s: number) => (s < 60 ? `${Math.max(1, Math.round(s))} s` : `${Math.floor(s / 60)} min ${String(Math.round(s % 60)).padStart(2, '0')} s`);
57
+
58
+ /** Where one character is in this step: the stage index (STAGES for a model), done, or failed. */
59
+ function where(step: string, key: string, w: CastWork[string]) {
60
+ const has = (f: string) => w.files[f] !== undefined;
61
+ if (step === 'model') {
62
+ if (has(`cast/${key}/model/model.json`)) return { at: STAGES.length, done: true };
63
+ if (/no 3D model/.test(w.said)) return { at: STAGES.length, done: true, failed: w.said.replace(/^no 3D model: /, '') };
64
+ return { at: /rigging/.test(w.said) ? 2 : /TRELLIS/.test(w.said) ? 1 : 0, done: false };
65
+ }
66
+ if (step === 'sprites') return { at: 0, done: has(`cast/${key}/sprites/sprites.json`) };
67
+ if (step === 'faces') return { at: 0, done: has(`cast/${key}/face-attempt.json`) || /face (prepared|unchanged)/.test(w.said) };
68
+ return { at: 0, done: has(`cast/${key}.png`) && /\.png · /.test(w.said) };
69
+ }
70
+
71
+ /** The pictures of this step for one character, landed or still to come. */
72
+ function slots(step: string, key: string, w: CastWork[string]): Array<{ name: string; file?: string; v?: number }> {
73
+ if (step === 'model') return VIEWS.map((v) => ({ name: v, file: w.files[`cast/${key}/model/${v}.png`] ? `cast/${key}/model/${v}.png` : undefined, v: w.files[`cast/${key}/model/${v}.png`] }));
74
+ if (step === 'sprites') {
75
+ const painted = Object.keys(w.files).filter((f) => /\/sprites\/(?:talk-)?painted-[\w-]+\.png$/.test(f));
76
+ return painted.length ? painted.map((f) => ({ name: f.split('/').at(-1)!.replace(/\.png$/, '').replace('painted-', ''), file: f, v: w.files[f] })) : [{ name: 'walk' }];
77
+ }
78
+ if (step === 'faces') {
79
+ // What it works from (the painted figure) and the mouth shapes as they are painted and checked.
80
+ const atlas = newestFace(key, w);
81
+ return [{ name: 'works from', file: `cast/${key}.png`, v: 0 }, { name: 'mouth shapes', file: atlas?.[0], v: atlas?.[1] }];
82
+ }
83
+ return [`cast/${key}-painting.png`, `cast/${key}.png`].map((f, i) => ({ name: i ? 'cut out' : 'painted', file: w.files[f] ? f : undefined, v: w.files[f] }));
84
+ }
85
+
86
+ export function CastWorkStage({ slug, step, work, running, failed, since, took, version, only, onOpen }: {
87
+ slug: string; step: string; work: CastWork; running: boolean; since?: number; took: number; version: number;
88
+ /** Why the step stopped, in plain words, when it failed. */
89
+ failed?: string;
90
+ /** The character a job of one was started for (cast_animate, cast_redo). */
91
+ only?: string;
92
+ onOpen: (key: string) => void;
93
+ }) {
94
+ const [cast, setCast] = useState<Who[] | null>(null);
95
+ useEffect(() => void call<Who[]>('cast_status', { slug }).then(setCast).catch(() => setCast([])), [slug, version]);
96
+ const [now, setNow] = useState(Date.now());
97
+ useEffect(() => {
98
+ if (!running) return;
99
+ const t = setInterval(() => setNow(Date.now()), 1000);
100
+ return () => clearInterval(t);
101
+ }, [running]);
102
+ const known = new Set((cast ?? []).map((c) => c.key));
103
+ // Who this step touched, newest first; a job for one character shows that one from the start.
104
+ const keys = Object.entries(work)
105
+ .filter(([k, w]) => known.has(k) && (Object.keys(w.files).some((f) => castFile(f)?.[1] === step) || (step === 'model' && /sides|TRELLIS|rigging|3D model/.test(w.said)) || (step === 'faces' && /face|mouth|facial|lips/.test(w.said))))
106
+ .sort((a, b) => b[1].at - a[1].at).map(([k]) => k);
107
+ if (only && known.has(only) && !keys.includes(only)) keys.unshift(only);
108
+ const [pick, setPick] = useState<string | null>(null);
109
+ // A finished faces step shows everyone's outcome at once rather than the last one it touched.
110
+ const focus = step === 'faces' && !running ? undefined : pick && keys.includes(pick) ? pick : keys[0];
111
+ const empty = { files: {}, said: '', at: 0, began: {} };
112
+ const elapsed = since ? (now - since) / 1000 : 0;
113
+ // A job for one character takes about one character's time, not the build's whole model step.
114
+ const left = Math.max(0, (only && step === 'model' ? STAGES.reduce((sum, x) => sum + x.s, 0) : took) - elapsed);
115
+
116
+ return (
117
+ <div class="cast-work" data-lightbox-group>
118
+ <div class="cw-head">
119
+ <strong>{WHAT[step] ?? step}</strong>
120
+ <span class="detail">
121
+ {running ? (since ? `${clock(elapsed)} so far · ${left > 5 ? `about ${clock(left)} left` : 'a little longer than usual'}` : 'starting…') : failed ? `stopped: ${failed}` : 'done'}
122
+ {keys.length > 1 ? ` · ${keys.length} characters` : ''}
123
+ </span>
124
+ </div>
125
+ {!focus && step === 'faces' && cast && cast.some((c) => c.figure) && (
126
+ // Who gets a talking face, from the figure it is painted from: ready (its mouth shapes), failed and why, or still to come.
127
+ <ul class="cw-faces">
128
+ {cast.filter((c) => c.figure).map((c) => {
129
+ const failedWhy = c.face?.attempt?.state === 'failed' ? c.face.attempt.reason : undefined;
130
+ return (
131
+ <li class={c.face?.ready ? 'ok' : failedWhy ? 'fail' : ''}>
132
+ <img src={fileUrl(slug, c.figure!.file, c.figure!.v)} data-lightbox={fileUrl(slug, c.figure!.file, c.figure!.v)} title={`${nice(c.key)}: its mouth shapes are painted from this figure. Click: large`} alt={c.key} />
133
+ <div>
134
+ <strong>{nice(c.key)}</strong>{' '}
135
+ <span class="detail">{c.face?.ready ? '✓ speaks with moving lips' : failedWhy ? `✕ not yet: ${failedWhy}` : running ? '○ waiting its turn' : '○ no talking face yet'}</span>
136
+ {c.face?.ready && <MouthShapes slug={slug} who={c.key} v={Date.parse(c.face.attempt?.at ?? '') || null} />}
137
+ {!c.face?.ready && <div class="row"><button type="button" class="ghost small" title="Its card: Refresh speech animation tries again (Muse Image, a few calls)" onClick={() => onOpen(c.key)}><Icon name="pencil" /> Details</button></div>}
138
+ </div>
139
+ </li>
140
+ );
141
+ })}
142
+ </ul>
143
+ )}
144
+ {!focus && (step !== 'faces' || !cast?.some((c) => c.figure)) && <div class="cw-wait"><span>{cast === null ? 'Reading the cast…' : named(step === 'model' ? 'Muse Image is about to paint the first character from four sides…' : step === 'sprites' ? 'Muse Image is about to paint the first sprite sheet…' : step === 'faces' ? 'Muse Image is about to paint the first mouth shapes…' : 'Muse Image is painting the cast…')}</span></div>}
145
+ {focus && (() => {
146
+ const w = work[focus] ?? empty;
147
+ const c = cast?.find((x) => x.key === focus);
148
+ const st = { ...where(step, focus, w), ...(failed && !where(step, focus, w).done && { stopped: true }) } as ReturnType<typeof where> & { stopped?: boolean };
149
+ // This character's time in this step: in a whole build it was painted with the cast half an hour before.
150
+ const mine = w.began[step] ? (now - w.began[step]) / 1000 : 0;
151
+ return (
152
+ <section class={`cw-focus${st.done ? ' done' : ''}`}>
153
+ <div class="cw-who">
154
+ {c?.figure ? <img src={fileUrl(slug, c.figure.file, w.files[c.figure.file] ?? c.figure.v)} data-lightbox={fileUrl(slug, c.figure.file, w.files[c.figure.file] ?? c.figure.v)} title="Click: large" alt="" /> : <div class="noimg" />}
155
+ <div class="cw-text">
156
+ <h3>{nice(focus)}{c?.role === 'hero' ? ' · hero' : ''}</h3>
157
+ <span class="detail">{st.stopped ? `Stopped at: ${w.said || 'the start'}` : st.failed ? `No 3D model: ${st.failed}` : st.done ? (step === 'model' ? 'The 3D model is ready' : 'Done') : w.said || 'waiting for the first picture…'}{!st.done && !st.stopped && mine > 1 ? ` · ${clock(mine)}` : ''}</span>
158
+ {step === 'model' && (
159
+ <ol class="cw-stages">
160
+ {STAGES.map((s, i) => <li class={i < st.at ? 'ok' : i === st.at && !st.done ? (st.stopped ? 'stop' : 'on') : ''} title={named(s.tip)}>{i < st.at ? '✓' : i === st.at && !st.done ? (st.stopped ? '✕' : '●') : '○'} {s.word} <small>{nowBy(s.by)}</small></li>)}
161
+ </ol>
162
+ )}
163
+ <div class="row">
164
+ {st.done && c && (c.stamps.model || c.stamps.rig || c.stamps.sprites) && <button type="button" class="ghost small" title="Looks, voice, 3D model and sprites of this character. Free to look" onClick={() => onOpen(focus)}><Icon name="pencil" /> Details</button>}
165
+ </div>
166
+ </div>
167
+ </div>
168
+ {step !== 'faces' && st.done && c && (c.stamps.model || c.stamps.sprites || c.stamps.rig)
169
+ ? <>
170
+ {/* The sides stay in sight after the step: what TRELLIS built the body from. */}
171
+ {step === 'model' && c.stamps.model && <Sides slug={slug} dir={`cast/${focus}/model`} v={c.stamps.model} />}
172
+ <FigurePreview key={`${focus}-${c.stamps.model}-${c.stamps.sprites}`} slug={slug} who={focus} stamps={c.stamps} sides={step !== 'model'} />
173
+ </>
174
+ : (
175
+ <div class="cw-slots">
176
+ {slots(step, focus, w).map((s) => s.name === 'works from' && c?.figure ? { ...s, file: c.figure.file, v: c.figure.v } : s).map((s) => (
177
+ <figure class={s.file ? 'in' : ''}>
178
+ {s.file ? <img src={fileUrl(slug, s.file, s.v)} data-lightbox={fileUrl(slug, s.file, s.v)} title="Click: large" alt={s.name} /> : <div class={`to-come${failed ? ' still' : ''}`} />}
179
+ <figcaption>{s.name}</figcaption>
180
+ </figure>
181
+ ))}
182
+ </div>
183
+ )}
184
+ </section>
185
+ );
186
+ })()}
187
+ {keys.length > 1 && focus && (
188
+ <ul class="cw-others" aria-label="Others in this step">
189
+ {keys.filter((k) => k !== focus).map((k) => {
190
+ const w = work[k] ?? empty;
191
+ const c = cast?.find((x) => x.key === k);
192
+ const st = where(step, k, w);
193
+ return (
194
+ <li>
195
+ <button type="button" class="ghost" title={`Watch ${nice(k)}`} onClick={() => setPick(k)}>
196
+ {c?.figure ? <img src={fileUrl(slug, c.figure.file, w.files[c.figure.file] ?? c.figure.v)} alt="" /> : <span class="noimg" />}
197
+ <span>{nice(k)}</span>
198
+ <small>{st.failed ? '✕ no model' : st.done ? '✓ done' : step === 'model' ? `● ${STAGES[st.at].word}` : '● working'}</small>
199
+ </button>
200
+ </li>
201
+ );
202
+ })}
203
+ </ul>
204
+ )}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,178 @@
1
+ // The cast: one card per character with their figure, how they look, what the runtime has for them and
2
+ // their voice. Every change is an action (cast_redo, voice_set) kept in cast/fixes.json.
3
+ import { useEffect, useState } from 'preact/hooks';
4
+ import { call, fileUrl } from './api.ts';
5
+ import { named } from './build.tsx';
6
+ import { FigurePreview, MouthShapes } from './figure.tsx';
7
+ import { Icon, type IconName } from './icons.tsx';
8
+
9
+ type Pic = { file: string; v: number | null } | null;
10
+ type Character = {
11
+ key: string; role: 'hero' | 'npc' | 'player' | 'narrator'; place: string | null; looks: string; looksNow: string;
12
+ fix: { looks?: string; take?: number; voice?: { voice: string } } | null;
13
+ figure: Pic; painting: Pic; rig: boolean; model: boolean; sprites: boolean; modelIssue: string | null;
14
+ rigIssue?: string | null; spriteIssues?: string[]; moves?: string;
15
+ modelJob: { id: string; state: 'queued' | 'running' } | null;
16
+ modelAttempt: { state: 'ready' | 'fallback' | 'failed'; reason: string | null; at: string } | null;
17
+ body: { height: number; upright: boolean } | null;
18
+ stamps: { rig: number | null; model: number | null; sprites: number | null };
19
+ voice: { voice: string; why: string; stability: number; style: number } | null;
20
+ face?: { ready: boolean; image: boolean; rig: boolean; spriteViews: number; model3d?: {mouth:boolean;method?:string|null;views?:string[]} | null; attempt?: { state?: string; reason?: string; at?: string } | null };
21
+ lines: number; flagged: number; sample: { text: string; file: string } | null;
22
+ voiceFailures?: Array<{ text: string; reason: string }>;
23
+ voiceWarnings?: string[];
24
+ };
25
+ type Voice = { id: string; name: string; sounds: string; fits?: boolean };
26
+
27
+ export function CastView({ slug, version, onError, focus }: { slug: string; version: number; onError: (m: string) => void; focus?: string }) {
28
+ const [cast, setCast] = useState<Character[] | null>(null);
29
+ useEffect(() => void call<Character[]>('cast_status', { slug }).then(setCast).catch((e) => onError(e.message)), [slug, version]);
30
+ // Opened on one character: scroll to their card once it is there.
31
+ // Pictures above it load late and push it down: scroll again once they have.
32
+ useEffect(() => {
33
+ if (!cast || !focus) return;
34
+ const go = () => document.getElementById(`cast-${focus}`)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
35
+ go();
36
+ const again = setTimeout(go, 450);
37
+ return () => clearTimeout(again);
38
+ }, [!!cast, focus]);
39
+ if (!cast) return <p class="detail">Reading the cast…</p>;
40
+ return (
41
+ <div class="cast-view">
42
+ <ul class="cast">
43
+ {cast.map((c) => <Card key={c.key} slug={slug} c={c} onError={onError} focused={c.key === focus} />)}
44
+ </ul>
45
+ <Items slug={slug} version={version} onError={onError} focus={focus?.startsWith('item-') ? focus.slice(5) : undefined} />
46
+ </div>
47
+ );
48
+ }
49
+
50
+ type Item = { key: string; name: string; from: { place: string; thing: string } | null; how: 'cut' | 'redraw' | 'paint' | null; icon: Pic; fix: { how?: string; hint?: string; take?: number } | null };
51
+ const HOW = { cut: 'cut from the painting', redraw: 'redrawn by Muse', paint: 'painted by Muse' };
52
+
53
+ /** The inventory: one icon per story item, with where it comes from; a hint or another take is icon_redo. */
54
+ function Items({ slug, version, onError, focus }: { slug: string; version: number; onError: (m: string) => void; focus?: string }) {
55
+ const [items, setItems] = useState<Item[] | null>(null);
56
+ const [hints, setHints] = useState<Record<string, string>>({});
57
+ useEffect(() => void call<Item[]>('icons_status', { slug }).then(setItems).catch(() => setItems([])), [slug, version]);
58
+ // Opened on one item (a click on the shelf): scroll to it once the list is there, again once pictures above loaded.
59
+ useEffect(() => {
60
+ if (!items || !focus) return;
61
+ const go = () => document.getElementById(`item-${focus}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
62
+ go();
63
+ const again = setTimeout(go, 600);
64
+ return () => clearTimeout(again);
65
+ }, [!!items, focus]);
66
+ if (!items?.length) return null;
67
+ const act = (name: string, input: object) => call(name, { slug, ...input }).catch((e) => onError((e as Error).message));
68
+ return (
69
+ <section class="items">
70
+ <h3>Inventory</h3>
71
+ <ul>
72
+ {items.map((it) => (
73
+ <li key={it.key} id={`item-${it.key}`} class={it.key === focus ? 'focus' : undefined}>
74
+ {it.icon ? <img src={fileUrl(slug, it.icon.file, it.icon.v)} alt={it.name} /> : <div class="noimg">no icon yet</div>}
75
+ <div class="body">
76
+ <strong>{it.name}</strong>
77
+ <span class="detail">{it.from ? `picked up: ${it.from.place} / ${it.from.thing}` : 'handed over'}{it.how ? ` · ${named(HOW[it.how])}` : ''}</span>
78
+ <form class="row" onSubmit={(e) => (e.preventDefault(), (hints[it.key] ?? '').trim().length >= 3 && void act('icon_redo', { item: it.key, hint: hints[it.key].trim() }))}>
79
+ <input value={hints[it.key] ?? it.fix?.hint ?? ''} placeholder="How it should look" title="Your words for the icon, e.g. 'a rusty brass key'. Press Redraw to use them" aria-label={`How ${it.name} should look`} onInput={(e) => setHints({ ...hints, [it.key]: e.currentTarget.value })} />
80
+ <button type="button" class="ghost small" title={named(it.how === 'cut' ? 'This icon is cut from the painting, so another take comes out the same. Write how it should look and press Redraw.' : `Muse ${it.how === 'paint' ? 'paints' : 'redraws'} the icon again, a new version of the same thing. 1 Muse Image call`)} onClick={() => act('icon_redo', { item: it.key, anotherTake: true })}>Another take</button>
81
+ <button type="submit" class={`${(hints[it.key] ?? '').trim().length >= 3 ? 'primary' : 'ghost'} small`} title={named('Muse redraws the icon the way you wrote, kept across rebuilds. 1 Muse Image call')}>Redraw</button>
82
+ </form>
83
+ </div>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ </section>
88
+ );
89
+ }
90
+
91
+ function Card({ slug, c, onError, focused }: { slug: string; c: Character; onError: (m: string) => void; focused?: boolean }) {
92
+ // The voices that fit this character (gender and age seen in the figure) come first; the rest are marked.
93
+ const [voices, setVoices] = useState<Voice[] | null>(null);
94
+ const loadVoices = () => voices ?? call<{ voices: Voice[] } | Voice[]>('voice_list', { slug, key: c.key })
95
+ .then((r) => setVoices(Array.isArray(r) ? r : r.voices)).catch((e) => onError(e.message));
96
+ const [looks, setLooks] = useState(c.looksNow);
97
+ const [busy, setBusy] = useState(false);
98
+ // Opened on this character: it moves right away.
99
+ const [moving, setMoving] = useState(!!focused && !!(c.rig || c.model || c.sprites));
100
+ useEffect(() => setLooks(c.looksNow), [c.looksNow]);
101
+ const act = async (name: string, input: object) => {
102
+ setBusy(true);
103
+ try {
104
+ await call(name, { slug, key: c.key, ...input });
105
+ } catch (e) {
106
+ onError((e as Error).message);
107
+ }
108
+ setBusy(false);
109
+ };
110
+ const changed = looks.trim() !== c.looksNow && looks.trim().length >= 10;
111
+ const creature = c.body?.upright === false;
112
+ // What the game has of this character, as small signs; each says what it is on hover.
113
+ const parts = ([
114
+ creature && ['paw', 'A creature: moves as one body (hops, squashes, leans), no skeleton'],
115
+ c.figure && ['figure', `Figure: painted by Muse Image and cut out by SAM${c.moves ? `. In the game it ${c.moves.replace(/^the /, 'is the ')}` : ''}`],
116
+ c.rig && ['bone', '2D rig: the figure cut into parts that walk; used when there is no 3D model or sprites'],
117
+ c.model && ['cube', '3D model: built by TRELLIS from four painted sides'],
118
+ c.sprites && ['film', 'Sprites: painted walk and talk frames, what the game draws first'],
119
+ ].filter(Boolean)) as Array<[IconName, string]>;
120
+ return (
121
+ <li id={`cast-${c.key}`} class={`who ${c.role}${focused ? ' focus' : ''}`}>
122
+ {c.role !== 'narrator' && (c.figure ? <img src={fileUrl(slug, c.figure.file, c.figure.v)} alt={c.key} loading="lazy" /> : <div class="noimg">not painted yet</div>)}
123
+ <div class="body">
124
+ <div class="row">
125
+ <strong>{c.key}</strong>
126
+ <span class="badge">{c.role === 'hero' ? 'hero' : c.role === 'player' ? 'hero for another gamepad' : c.role === 'narrator' ? 'narrator' : `NPC · ${c.place}`}</span>
127
+ <span class="grow" />
128
+ {parts.length > 0 && <span class="has">{parts.map(([sign, what]) => <span class="sign" title={named(what)}><Icon name={sign} /></span>)}</span>}
129
+ {c.modelIssue && <span class="detail">Using 2D: {c.modelIssue}</span>}
130
+ {c.rigIssue && !c.model && !c.sprites && <span class="detail">No 2D walk: {c.rigIssue}</span>}
131
+ {!!c.spriteIssues?.length && <button type="button" class="ghost small" disabled={busy} title={named(`${c.spriteIssues.join('; ')}. Muse paints new walk and talk sheets over the 3D model, the character stays as painted. Kept across rebuilds. About 2-10 Muse Image calls per direction`)} onClick={() => act('sprites_redo', {})}>New walk sheets</button>}
132
+ {c.model && c.modelAttempt?.state === 'failed' && <span class="detail">Keeping previous model: {c.modelAttempt.reason}</span>}
133
+ {c.role !== 'narrator' && c.figure && !c.model && <button type="button" class="ghost small" disabled={busy || !!c.modelJob} title={named(`Muse paints ${c.key} from 4 sides and TRELLIS builds a 3D model${creature ? ': no skeleton, in the game it hops, squashes, leans and blinks as one body' : ''}. Animation settings are chosen automatically and kept across rebuilds.`)} onClick={() => act('cast_animate', {})}>{c.modelJob ? c.modelJob.state === 'queued' ? '3D queued…' : 'Building 3D…' : c.modelIssue ? 'Retry 3D' : 'Animate in 3D'}</button>}
134
+ {(c.rig || c.model || c.sprites) && <button type="button" class={`ghost small round${moving ? ' on' : ''}`} aria-label={moving ? 'Close the preview' : 'See it move'} title={moving ? 'Close the preview' : 'See it move: walk, stand and talk as the game draws it. Free'} onClick={() => setMoving(!moving)}>{moving ? '■' : '▶'}</button>}
135
+ </div>
136
+ {c.role !== 'narrator' && (<>
137
+ <div class="row" style="margin-bottom:8px">
138
+ <button type="button" class="ghost small" disabled={busy || !c.figure} onClick={() => void act('faces_prepare', {})}>{c.face?.ready ? 'Refresh speech animation' : 'Prepare speech animation'}</button>
139
+ {c.face?.ready && <small>{c.face.model3d?.mouth ? `2D + 3D speech ready (${c.face.model3d.method === 'morphs' ? 'facial morphs' : 'painted surfaces'})` : '2D speaking face ready'}</small>}
140
+ {c.face?.ready && <details class="detail"><summary title="The mouth shapes this character speaks with, cut from its face atlas. Free to look">Mouth shapes</summary><MouthShapes slug={slug} who={c.key} v={Date.parse(c.face.attempt?.at ?? '') || null} /></details>}
141
+ {c.face?.attempt?.state === 'failed' && <details class="detail"><summary>Speech animation incomplete · previous appearance kept</summary><p>{c.face.attempt.reason}</p></details>}
142
+ </div>
143
+ <form class="say" onSubmit={(e) => (e.preventDefault(), changed && void act('cast_redo', { looks: looks.trim() }))}>
144
+ <textarea rows={3} value={looks} title="How the character looks. Change it and press Repaint" onInput={(e) => setLooks(e.currentTarget.value)} aria-label={`How ${c.key} looks`} />
145
+ <div class="row">
146
+ {c.fix?.looks && <span class="detail">your description{c.fix.take ? ` · take ${c.fix.take + 1}` : ''}</span>}
147
+ <span class="grow" />
148
+ <button type="button" class="ghost small" disabled={busy} title={named('Muse paints this character again from the same description and SAM cuts the figure out. 1 Muse Image + 1 SAM call. Sprites and the 3D model are not remade')} onClick={() => act('cast_redo', { anotherTake: true })}>Another take</button>
149
+ <button type="submit" class="primary small" disabled={busy || !changed} title={named(changed ? 'Muse paints the character from your new description, kept across rebuilds. 1 Muse Image + 1 SAM call' : 'Change the description first (at least 10 letters)')}>Repaint</button>
150
+ </div>
151
+ </form>
152
+ </>)}
153
+ <div class="row voice">
154
+ {c.voice
155
+ ? <>
156
+ <label title="Pick another voice. Their lines are spoken again: ElevenLabs, one call per line of this character">Voice <select onFocus={loadVoices} onMouseDown={loadVoices} disabled={busy} value={c.voice.voice} onChange={(e) => act('voice_set', { voice: e.currentTarget.value })}>
157
+ {(voices ?? [{ id: c.voice.voice, name: c.voice.why, sounds: '' }]).map((v) => <option value={v.id} title={[v.name, v.sounds].filter(Boolean).join(' · ')}>{v.fits === false ? '(does not fit) ' : ''}{short(v.name, 32)}{v.sounds ? ` · ${short(v.sounds, 40)}` : ''}</option>)}
158
+ </select></label>
159
+ <span class="detail">{c.lines} line{c.lines === 1 ? '' : 's'}{c.flagged ? `, ${c.flagged} misheard` : ''}</span>
160
+ </>
161
+ : <span class="detail">{c.lines ? `${c.lines} lines, no voice yet` : 'no spoken lines'}</span>}
162
+ </div>
163
+ {c.sample && <div class="sample"><audio controls preload="none" src={fileUrl(slug, c.sample.file)} /><span class="detail">“{c.sample.text}”</span></div>}
164
+ {!!c.voiceFailures?.length && <p class="detail" role="status">{c.voiceFailures.length} spoken lines unavailable: {c.voiceFailures[0].reason}</p>}
165
+ {c.voiceWarnings?.map((w) => <p class="detail" role="status">{w}</p>)}
166
+ </div>
167
+ {moving && <FigurePreview slug={slug} who={c.key} stamps={c.stamps} />}
168
+ </li>
169
+ );
170
+ }
171
+
172
+ // A designed voice is named by its description; keep the select readable and the whole text in the tooltip.
173
+ function short(text: string, max: number): string {
174
+ if (text.length <= max) return text;
175
+ const cut = text.slice(0, max);
176
+ const stop = Math.max(cut.lastIndexOf(','), cut.lastIndexOf(' '));
177
+ return `${(stop > max / 2 ? cut.slice(0, stop) : cut).replace(/[,\s]+$/, '')}…`;
178
+ }
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import { shrink } from './camera.tsx';
3
+
4
+ export type ConceptChange = { change?: string; reference?: { name: string; data: string } };
5
+
6
+ /** A small conversation beside the picture, rather than another setup form. */
7
+ export function ConceptEdit({ label, disabled, onGenerate, onClose, onDraftChange, purpose = 'change' }: {
8
+ label: string; disabled: boolean; purpose?: 'change' | 'style'; onGenerate: (change: ConceptChange) => void; onClose: () => void; onDraftChange?: () => void;
9
+ }) {
10
+ const [words, setWords] = useState('');
11
+ const [reference, setReference] = useState<ConceptChange['reference']>();
12
+ const [reading, setReading] = useState(false);
13
+ const [error, setError] = useState('');
14
+ const picker = useRef<HTMLInputElement>(null);
15
+ const form = useRef<HTMLFormElement>(null);
16
+ const uploading = useRef(false);
17
+ useEffect(() => { onDraftChange?.(); }, [words, reference]);
18
+ useEffect(() => {
19
+ form.current?.querySelector('textarea')?.focus({ preventScroll: true });
20
+ form.current?.scrollIntoView({ block: 'center', behavior: 'instant' });
21
+ }, []);
22
+ const add = async (file?: File) => {
23
+ if (!file || disabled || uploading.current) return;
24
+ if (!file.type.startsWith('image/')) { setError('Choose an image for this reference.'); return; }
25
+ uploading.current = true; setReading(true); setError('');
26
+ try {
27
+ const data = await shrink(file);
28
+ if (data.length > 8_000_000) throw new Error('This picture is too large. Try a smaller image.');
29
+ setReference({ name: (file.name || 'pasted-image').slice(0, 120), data });
30
+ } catch (e) { setError((e as Error).message); }
31
+ finally { uploading.current = false; setReading(false); }
32
+ };
33
+ return <form ref={form} class="concept-edit" aria-label={`Change ${label}`} onSubmit={e => {
34
+ e.preventDefault();
35
+ if (!disabled && !reading && (words.trim() || reference)) onGenerate({ ...(words.trim() && { change: words.trim() }), ...(reference && { reference }) });
36
+ }} onPaste={e => {
37
+ const file = [...(e.clipboardData?.files ?? [])].find(f => f.type.startsWith('image/'));
38
+ if (file) { e.preventDefault(); void add(file); }
39
+ }}>
40
+ <label>{purpose === 'style' ? 'What should your world look like?' : 'What would you change?'}<textarea autoFocus aria-label={`Changes for ${label}`} value={words} maxLength={purpose === 'style' ? 500 : 1200} rows={3} disabled={disabled} placeholder={purpose === 'style' ? 'Blue ink, grainy paper, warm lantern light…' : 'A bigger hat, warmer colours…'} onInput={e => setWords(e.currentTarget.value)} /></label>
41
+ <p>{purpose === 'style' ? 'Describe a look, or paste a picture for its colours and textures.' : 'Describe the picture, or paste a reference here.'}</p>
42
+ {reference && <div class="concept-reference"><button type="button" class="ghost bare" data-lightbox={reference.data} aria-label="Enlarge reference"><img src={reference.data} alt="Your reference" /></button><button type="button" class="ghost small" disabled={disabled || reading} onClick={() => setReference(undefined)}>Remove</button></div>}
43
+ <input hidden ref={picker} type="file" accept="image/*" aria-label={`Reference for ${label}`} onChange={e => { void add(e.currentTarget.files?.[0]); e.currentTarget.value = ''; }} />
44
+ <div class="concept-edit-tools"><button type="button" class="ghost small" disabled={disabled || reading} onClick={() => picker.current?.click()}>{reading ? 'Reading picture…' : '+ Picture'}</button><button type="button" class="ghost small" disabled={disabled || reading} onClick={onClose}>Cancel</button><button type="submit" class="primary small" disabled={disabled || reading || !words.trim() && !reference}>{purpose === 'style' ? disabled ? 'Preparing your style…' : 'Preview my style' : 'Regenerate'}</button></div>
45
+ {error && <p class="error" role="alert">{error}</p>}
46
+ </form>;
47
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { CreativeChoices } from '../src/world/idea-review.ts';
3
+ import { fileUrl } from './api.ts';
4
+
5
+ type CreatorBrief = { idea: string; choices?: CreativeChoices };
6
+
7
+ /** The build keeps its full instructions; the conversation shows what the person actually wrote. */
8
+ export function useCreatorBrief(slug: string): CreatorBrief | null {
9
+ const [saved, setSaved] = useState<{ slug: string; brief: CreatorBrief } | null>(null);
10
+ useEffect(() => {
11
+ const controller = new AbortController();
12
+ const options = { cache: 'no-store' as const, signal: controller.signal };
13
+ const version = Date.now();
14
+ void (async () => {
15
+ const source = await fetch(fileUrl(slug, 'creative-brief.json', version), options)
16
+ .then(r => r.ok ? r.json() : null).catch(() => null);
17
+ let brief: CreatorBrief;
18
+ if (source && typeof source.idea === 'string' && source.idea.trim()) {
19
+ const choices = CreativeChoices.safeParse(source.choices);
20
+ brief = { idea: source.idea.trim(), ...(choices.success && { choices: choices.data }) };
21
+ } else {
22
+ const idea = await fetch(fileUrl(slug, 'idea.txt', version), options)
23
+ .then(r => r.ok ? r.text() : '').catch(() => '');
24
+ brief = { idea: idea.trim() };
25
+ }
26
+ if (!controller.signal.aborted) setSaved({ slug, brief });
27
+ })();
28
+ return () => controller.abort();
29
+ }, [slug]);
30
+ return saved?.slug === slug ? saved.brief : null;
31
+ }
32
+
33
+ export function CreatorIdea({ brief }: { brief: CreatorBrief }) {
34
+ const choices = brief.choices;
35
+ const hasChoices = choices && (choices.answers.length > 0 || choices.style || choices.note);
36
+ return <div class="creator-idea">
37
+ <div class="idea-bubble">{brief.idea}</div>
38
+ {hasChoices && <details class="creator-choices">
39
+ <summary>Your choices{choices.answers.length > 0 && <span> · {choices.answers.length} {choices.answers.length === 1 ? 'answer' : 'answers'}</span>}{choices.style && <span> · visual style</span>}</summary>
40
+ <dl>{choices.answers.map(a => <div><dt>{a.question}</dt><dd>{a.answer}</dd></div>)}
41
+ {choices.style && <div><dt>Visual style</dt><dd>{choices.style}</dd></div>}
42
+ {choices.note && <div><dt>Your note</dt><dd>{choices.note}</dd></div>}
43
+ </dl>
44
+ </details>}
45
+ </div>;
46
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import { call } from './api.ts';
3
+ import { ConceptEdit, type ConceptChange } from './concept-edit.tsx';
4
+ import type { ReviewInput } from './idea-review.tsx';
5
+
6
+ export type Look = { title: string; description: string; reference?: string };
7
+
8
+ export function CustomLook({ input, scene, busy, onChoose, onClose }: {
9
+ input: ReviewInput; scene: string; busy: boolean; onChoose: (look: Look) => void; onClose: () => void;
10
+ }) {
11
+ const [draft, setDraft] = useState<{ look: Look; image: string }>();
12
+ const [working, setWorking] = useState(false);
13
+ const [error, setError] = useState('');
14
+ const active = useRef(true), pending = useRef(false);
15
+ useEffect(() => () => { active.current = false; }, []);
16
+ const preview = async ({ change, reference }: ConceptChange) => {
17
+ if (pending.current || busy) return;
18
+ pending.current = true; setWorking(true); setError('');
19
+ try {
20
+ const look = await call<Look>('idea_style', { words: change ?? '', ...(reference && { reference }) });
21
+ const { image } = await call<{ image: string }>('idea_preview', { idea: input.idea, scene, style: look.description, ...(look.reference && { styleReference: look.reference }), references: input.photos.slice(0, 4).map(p => p.data) });
22
+ if (active.current) setDraft({ look, image });
23
+ } catch (e) { if (active.current) setError((e as Error).message); }
24
+ finally { pending.current = false; if (active.current) setWorking(false); }
25
+ };
26
+ return <section class="custom-look" aria-label="Your own style" aria-busy={working}>
27
+ <ConceptEdit label="your style" purpose="style" disabled={busy || working} onGenerate={preview} onClose={onClose} onDraftChange={() => setDraft(undefined)} />
28
+ {error && <p class="error" role="alert">{error}</p>}
29
+ {working && <p role="status"><span class="review-spinner" aria-hidden="true" /> Sketching your style…</p>}
30
+ {draft && <div class="custom-look-preview">
31
+ <button type="button" class="ghost bare" data-lightbox={draft.image} aria-label="Enlarge your style preview"><img src={draft.image} alt={draft.look.title} /></button>
32
+ <p>{draft.look.description}</p>
33
+ <button type="button" class="amber" disabled={busy || working} onClick={() => onChoose(draft.look)}>Use this style</button>
34
+ </div>}
35
+ </section>;
36
+ }
@@ -0,0 +1,28 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { call } from './api.ts';
3
+
4
+ type FacesState = { enabled: boolean; env: string | null };
5
+
6
+ /** Setup: talking mouths on or off. Off by default: barely visible in play, costly in calls and build time. */
7
+ export function FacesSetup() {
8
+ const [state, setState] = useState<FacesState | null>(null);
9
+ const [busy, setBusy] = useState(false);
10
+ const [error, setError] = useState('');
11
+ useEffect(() => { void call<FacesState>('faces_get').then(setState).catch((e) => setError(e.message)); }, []);
12
+ const toggle = async (enabled: boolean) => {
13
+ setBusy(true); setError('');
14
+ try { setState(await call<FacesState>('faces_set', { enabled })); }
15
+ catch (e) { setError((e as Error).message); }
16
+ finally { setBusy(false); }
17
+ };
18
+ return <section class="budget blender" aria-label="Talking mouths">
19
+ <h2>Talking mouths</h2>
20
+ <label class="switch-row" title="Muse paints mouth shapes for each character's portrait and Spark checks them: about $0.20 and a few minutes a character">
21
+ <span>Animate lips when characters speak</span>
22
+ <input type="checkbox" role="switch" checked={!!state?.enabled} disabled={!state || busy || state.env !== null} onChange={(e) => void toggle(e.currentTarget.checked)} />
23
+ </label>
24
+ <p class="note">{state?.enabled ? 'On: after every place plays, each speaking character gets mouth shapes on its portrait (Muse Image, Spark).' : 'Off: characters speak with voice and gestures; builds skip the faces step. Mouths made earlier still play.'}</p>
25
+ {state?.env != null && <p class="note">Set by DIORAMA_FACES={state.env}. Unset it and restart Studio to switch it here.</p>}
26
+ {error && <p class="error" role="alert">{error}</p>}
27
+ </section>;
28
+ }
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <rect width="32" height="32" rx="7" fill="#f2ece1"/>
3
+ <defs><clipPath id="a"><path d="M8.6 27.4V15.2a7.4 7.4 0 0 1 14.8 0v12.2Z"/></clipPath></defs>
4
+ <g clip-path="url(#a)">
5
+ <rect width="32" height="32" fill="#3d4b7c"/>
6
+ <circle cx="19.2" cy="13.2" r="2.6" fill="#e0a050"/>
7
+ <path d="M6 22 C10 18.5 14 18.5 18 21 C21 23 24 22 27 19.5 V29 H6Z" fill="#f2ece1" opacity=".45"/>
8
+ <path d="M6 25.6 C11 22.4 16 22.6 21 24.8 C23 25.8 25 25.4 27 24.8 V29 H6Z" fill="#f2ece1"/>
9
+ </g>
10
+ <path d="M6.4 28.2V15.2a9.6 9.6 0 0 1 19.2 0v13Z" fill="none" stroke="#2b2723" stroke-width="2.6" stroke-linejoin="round"/>
11
+ </svg>