@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,314 @@
1
+ // What the runtime will move for a character, seen without opening the game: the 3D model turned by hand
2
+ // (walking, standing or talking with the same posing as the page), the 2D cut-out rig walking, and the
3
+ // sprite sheets Muse painted playing as loops. Everything loads only when the preview opens.
4
+ import { useEffect, useRef, useState } from 'preact/hooks';
5
+ import { GESTURES } from '../src/play/gestures.ts';
6
+ import { pose, sheetCell, type PartName, type Rig } from '../src/play/rig.ts';
7
+ import type { Figure3D, ModelInfo } from '../web/hero3d.ts';
8
+ import { fileUrl } from './api.ts';
9
+ import { named } from './build.tsx';
10
+
11
+ type Stamps = { rig: number | null; model: number | null; sprites: number | null };
12
+ type Facing = { sheet: string; yaw: number; stand?: number; off?: number[]; gestures?: string[] };
13
+ type SpriteInfo = { cols: number; rows: number; frames: number; facings: Record<string, Facing>; talk?: Record<string, Facing> };
14
+ type Mode = 'walk' | 'stand' | 'talk';
15
+
16
+ /** Seconds per walk cycle and per talking gesture: about what the game shows at a stroll. */
17
+ const STRIDE = 1.1;
18
+ const GESTURE = 1.4;
19
+ const TALL = 320;
20
+
21
+ const json = <T,>(url: string): Promise<T> => fetch(url).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${url}: ${r.status}`))));
22
+ async function image(src: string) {
23
+ const img = new Image();
24
+ img.src = src;
25
+ await img.decode();
26
+ return img;
27
+ }
28
+
29
+ /** Calls `draw` every animation frame with the seconds since the preview opened, until it unmounts. */
30
+ function useFrames(draw: (t: number) => void, deps: unknown[]) {
31
+ useEffect(() => {
32
+ const t0 = performance.now();
33
+ let raf = requestAnimationFrame(function tick(now) {
34
+ draw((now - t0) / 1000);
35
+ raf = requestAnimationFrame(tick);
36
+ });
37
+ return () => cancelAnimationFrame(raf);
38
+ }, deps);
39
+ }
40
+
41
+ const FIGURE_TIPS = {
42
+ model: 'The model that supplies the animation poses. Drag to turn it',
43
+ rig: 'The painting itself, walking: bent by its bones as one piece, so clothes stretch and never tear',
44
+ sprites: 'The generated sprites for each facing, looping',
45
+ };
46
+
47
+ export function FigurePreview({ slug, who, stamps, sides = true }: { slug: string; who: string; stamps: Stamps; sides?: boolean }) {
48
+ const tabs = ([['sprites', 'Sprites'], ['rig', '2D walk'], ['model', '3D model']] as const).filter(([k]) => stamps[k]);
49
+ const [tab, setTab] = useState(tabs[0]?.[0]);
50
+ if (!tab) return <p class="detail">No animation yet. Build the character to generate its animation.</p>;
51
+ return (
52
+ <div class="figure-preview">
53
+ <div class="tabs">
54
+ {tabs.map(([k, label]) => <button type="button" class={`ghost small${tab === k ? ' on' : ''}`} title={FIGURE_TIPS[k]} onClick={() => setTab(k)}>{label}</button>)}
55
+ </div>
56
+ {tab === 'model' && <ModelView slug={slug} dir={`cast/${who}/model`} v={stamps.model} />}
57
+ {tab === 'rig' && stamps.sprites && <p class="detail">The game walks this character with its sprites; this walk is the fallback.</p>}
58
+ {tab === 'model' && <p class="detail">This model supplies the animation poses. The game uses its sprites when available.</p>}
59
+ {tab === 'model' && sides && <Sides slug={slug} dir={`cast/${who}/model`} v={stamps.model} />}
60
+ {tab === 'rig' && <RigView slug={slug} who={who} v={stamps.rig} />}
61
+ {tab === 'sprites' && <SpritesView slug={slug} dir={`cast/${who}/sprites`} v={stamps.sprites} />}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /** The four painted sides the 3D body was built from, kept after the build; a click opens them large. */
67
+ export function Sides({ slug, dir, v }: { slug: string; dir: string; v: number | null }) {
68
+ const [gone, setGone] = useState<string[]>([]);
69
+ const sides = ['front', 'back', 'left', 'right'].filter((s) => !gone.includes(s));
70
+ if (!sides.length) return null;
71
+ return (
72
+ <div class="fig-sides" data-lightbox-group>
73
+ <span class="detail">{named('The 4 sides Muse Image painted for TRELLIS')}</span>
74
+ <div class="row">
75
+ {sides.map((s) => (
76
+ <figure>
77
+ <img src={fileUrl(slug, `${dir}/${s}.png`, v)} data-lightbox={fileUrl(slug, `${dir}/${s}.png`, v)} title="Click: large" alt={s} loading="lazy" onError={() => setGone((g) => [...g, s])} />
78
+ <figcaption>{s}</figcaption>
79
+ </figure>
80
+ ))}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function Modes({ modes, mode, set }: { modes: Mode[]; mode: Mode; set: (m: Mode) => void }) {
87
+ return (
88
+ <div class="tabs">
89
+ {modes.map((m) => <button type="button" class={`ghost small${mode === m ? ' on' : ''}`} title={`Show the ${m} animation`} onClick={() => set(m)}>{m}</button>)}
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** The rigged model in its own WebGL canvas; drag turns it (and tilts the camera), left alone it turns slowly. */
95
+ function ModelView({ slug, dir, v }: { slug: string; dir: string; v: number | null }) {
96
+ const host = useRef<HTMLDivElement>(null);
97
+ const label = useRef<HTMLSpanElement>(null);
98
+ const [fig, setFig] = useState<Figure3D | null>(null);
99
+ const [error, setError] = useState<string | null>(null);
100
+ const [mode, setMode] = useState<Mode>('walk');
101
+ const [spin, setSpin] = useState(true);
102
+ const view = useRef({ yaw: 0.6, elevation: 0.18, drag: null as { x: number; y: number } | null, last: 0 });
103
+ useEffect(() => {
104
+ let gone = false;
105
+ let loaded: Figure3D | undefined;
106
+ const url = (f: string) => fileUrl(slug, `${dir}/${f}`, v);
107
+ (async () => {
108
+ const info = await json<ModelInfo>(url('model.json'));
109
+ const { Figure3D } = await import('../web/hero3d.ts');
110
+ loaded = await Figure3D.load(info, url);
111
+ if (gone) return loaded.dispose();
112
+ host.current!.append(loaded.canvas);
113
+ setFig(loaded);
114
+ })().catch((e) => !gone && setError((e as Error).message));
115
+ return () => {
116
+ gone = true;
117
+ loaded?.canvas.remove();
118
+ loaded?.dispose();
119
+ };
120
+ }, [slug, dir, v]);
121
+ useFrames((t) => {
122
+ if (!fig) return;
123
+ const s = view.current;
124
+ if (spin && !s.drag) s.yaw += Math.max(0, t - s.last) * 0.5;
125
+ s.last = t;
126
+ const gesture = mode === 'talk' ? Math.floor(t / GESTURE) % GESTURES.length : undefined;
127
+ // A creature has no arms for gestures: it bounces on its words.
128
+ if (label.current) label.current.textContent = gesture === undefined || fig.creatureBody ? '' : GESTURES[gesture].name;
129
+ try {
130
+ const c = fig.draw({ phase: t / STRIDE, walking: mode === 'walk', gesture, yaw: s.yaw, elevation: s.elevation, px: TALL * devicePixelRatio, time: t });
131
+ c.style.height = `${(c.height / devicePixelRatio).toFixed(0)}px`;
132
+ } catch (e) {
133
+ fig.canvas.remove();
134
+ setError((e as Error).message);
135
+ setFig(null);
136
+ }
137
+ }, [fig, mode, spin]);
138
+ const drag = {
139
+ onPointerDown: (e: PointerEvent) => ((view.current.drag = { x: e.clientX, y: e.clientY }), (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)),
140
+ onPointerMove: (e: PointerEvent) => {
141
+ const s = view.current;
142
+ if (!s.drag) return;
143
+ s.yaw += (e.clientX - s.drag.x) * 0.012;
144
+ s.elevation = Math.max(-0.2, Math.min(1.2, s.elevation + (e.clientY - s.drag.y) * 0.006));
145
+ s.drag = { x: e.clientX, y: e.clientY };
146
+ },
147
+ onPointerUp: () => void (view.current.drag = null),
148
+ };
149
+ return (
150
+ <div class="moves">
151
+ <div class="row">
152
+ <Modes modes={['walk', 'stand', 'talk']} mode={mode} set={setMode} />
153
+ <label class="detail" title="Keep turning the model round by itself"><input type="checkbox" checked={spin} onChange={(e) => setSpin(e.currentTarget.checked)} /> turn</label>
154
+ <span class="grow" />
155
+ <span class="detail" ref={label} />
156
+ </div>
157
+ <div class="turntable" ref={host} {...drag} title="Drag to turn">
158
+ {!fig && <span class="detail">{error ?? 'Loading the model…'}</span>}
159
+ </div>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ /** The cut-out rig (rig.json and its part images) posed by src/play/rig.ts, as the 2D page draws it. */
165
+ function RigView({ slug, who, v }: { slug: string; who: string; v: number | null }) {
166
+ const canvas = useRef<HTMLCanvasElement>(null);
167
+ const [rig, setRig] = useState<{ rig: Rig; parts: Map<PartName, HTMLImageElement>; sheet?: HTMLImageElement } | null>(null);
168
+ const [error, setError] = useState<string | null>(null);
169
+ const [mode, setMode] = useState<Mode>('walk');
170
+ useEffect(() => {
171
+ let gone = false;
172
+ (async () => {
173
+ const r = await json<Rig>(fileUrl(slug, `cast/${who}/rig.json`, v));
174
+ const parts = new Map<PartName, HTMLImageElement>();
175
+ await Promise.all(r.parts.map(async (p) => parts.set(p.name, await image(fileUrl(slug, `cast/${who}/${p.file}`, v)))));
176
+ const sheet = r.sheet ? await image(fileUrl(slug, `cast/${who}/${r.sheet.file}`, v)) : undefined;
177
+ if (!gone) setRig({ rig: r, parts, sheet });
178
+ })().catch((e) => !gone && setError((e as Error).message));
179
+ return () => void (gone = true);
180
+ }, [slug, who, v]);
181
+ useFrames((t) => {
182
+ const c = canvas.current?.getContext('2d');
183
+ if (!rig || !c) return;
184
+ [c.canvas.width, c.canvas.height] = rig.rig.size;
185
+ const sheet = rig.rig.sheet;
186
+ if (sheet && rig.sheet) {
187
+ const i = sheetCell(sheet, t / STRIDE, mode === 'walk');
188
+ const [cw, ch] = sheet.cell;
189
+ c.drawImage(rig.sheet, (i % sheet.cols) * cw, Math.floor(i / sheet.cols) * ch, cw, ch, 0, 0, c.canvas.width, c.canvas.height);
190
+ } else {
191
+ const at = pose(rig.rig, t / STRIDE, mode === 'walk');
192
+ for (const part of rig.rig.parts) {
193
+ const img = rig.parts.get(part.name);
194
+ const p = at[part.name];
195
+ if (!img || !p) continue;
196
+ c.save();
197
+ c.translate(p.to[0], p.to[1]);
198
+ c.rotate(p.turn);
199
+ c.translate(-p.from[0], -p.from[1]);
200
+ c.drawImage(img, part.at[0], part.at[1]);
201
+ c.restore();
202
+ }
203
+ }
204
+ c.fillStyle = 'rgba(255,255,255,0.25)';
205
+ c.fillRect(0, rig.rig.ground, c.canvas.width, 2);
206
+ }, [rig, mode]);
207
+ return (
208
+ <div class="moves">
209
+ <Modes modes={['walk', 'stand']} mode={mode} set={setMode} />
210
+ {rig?.rig.check && !rig.rig.check.ok && <p class="detail">This walk tore when it was made ({rig.rig.check.why}), so the game slides the painted figure as one piece.</p>}
211
+ {rig && !rig.rig.sheet && <p class="detail">An old cut-out rig: run `diorama rig {slug}` (no Muse calls) for a walk that does not tear.</p>}
212
+ {rig ? <canvas ref={canvas} class="rig" /> : <span class="detail">{error ?? 'Loading the rig…'}</span>}
213
+ </div>
214
+ );
215
+ }
216
+
217
+ /** Every painted sheet as a loop: the walk facings (left ones are these mirrored in the game) and the talking cells. */
218
+ function SpritesView({ slug, dir, v }: { slug: string; dir: string; v: number | null }) {
219
+ const [sheets, setSheets] = useState<Array<{ name: string; talk: boolean; f: Facing; img: HTMLImageElement }> | null>(null);
220
+ const [info, setInfo] = useState<SpriteInfo | null>(null);
221
+ const [error, setError] = useState<string | null>(null);
222
+ // Walk, stand or talk: each shows only its own sheets (an NPC who only speaks has talk sheets and no walk).
223
+ const [picked, setMode] = useState<Mode | null>(null);
224
+ useEffect(() => {
225
+ let gone = false;
226
+ (async () => {
227
+ const i = await json<SpriteInfo>(fileUrl(slug, `${dir}/sprites.json`, v));
228
+ const list = [...Object.entries(i.facings ?? {}).map(([name, f]) => ({ name, talk: false, f })), ...Object.entries(i.talk ?? {}).map(([name, f]) => ({ name: `talk ${name}`, talk: true, f }))];
229
+ const loaded = await Promise.all(list.map(async (s) => ({ ...s, img: await image(fileUrl(slug, `${dir}/${s.f.sheet}`, v)) })));
230
+ if (!gone) (setInfo(i), setSheets(loaded));
231
+ })().catch((e) => !gone && setError((e as Error).message));
232
+ return () => void (gone = true);
233
+ }, [slug, dir, v]);
234
+ const canvases = useRef<Array<HTMLCanvasElement | null>>([]);
235
+ const labels = useRef<Array<HTMLSpanElement | null>>([]);
236
+ const modes: Mode[] = sheets ? [...(sheets.some((s) => !s.talk) ? ['walk', 'stand'] as Mode[] : []), ...(sheets.some((s) => s.talk) ? ['talk'] as Mode[] : [])] : [];
237
+ const mode: Mode | undefined = picked && modes.includes(picked) ? picked : modes[0];
238
+ const walking = mode === 'walk';
239
+ const shown = (sheets ?? []).filter((s) => s.talk === (mode === 'talk'));
240
+ useFrames((t) => {
241
+ if (!info || !sheets) return;
242
+ shown.forEach((s, k) => {
243
+ const c = canvases.current[k]?.getContext('2d');
244
+ if (!c) return;
245
+ const [cw, ch] = [s.img.width / info.cols, s.img.height / info.rows];
246
+ [c.canvas.width, c.canvas.height] = [cw, ch];
247
+ let cell: number;
248
+ if (s.talk) {
249
+ const n = s.f.gestures?.length ?? 1;
250
+ cell = Math.floor(t / GESTURE) % n;
251
+ if (labels.current[k]) labels.current[k]!.textContent = s.f.gestures?.[cell] ?? '';
252
+ } else {
253
+ const steps = [...Array(info.frames).keys()].filter((i) => !s.f.off?.includes(i));
254
+ cell = walking ? steps[Math.floor(((t / STRIDE) % 1) * steps.length) % steps.length] : s.f.stand ?? 0;
255
+ }
256
+ c.drawImage(s.img, (cell % info.cols) * cw, Math.floor(cell / info.cols) * ch, cw, ch, 0, 0, cw, ch);
257
+ });
258
+ }, [info, sheets, mode]);
259
+ if (!sheets) return <span class="detail">{error ?? 'Loading the sheets…'}</span>;
260
+ return (
261
+ <div class="moves">
262
+ {mode && <Modes modes={modes} mode={mode} set={setMode} />}
263
+ {info && mode !== 'talk' && sheets.some((s) => !s.talk) && <p class="detail">{info.frames} frames per walking direction</p>}
264
+ {!sheets.some((s) => !s.talk) && <p class="detail">No walk sheets: this character only talks in the game, so only talking was drawn.</p>}
265
+ <ul class="sheets">
266
+ {shown.map((s, k) => (
267
+ <li key={s.name}>
268
+ <canvas ref={(el) => void (canvases.current[k] = el)} data-lightbox={fileUrl(slug, `${dir}/${s.f.sheet}`, v)} title="Click: the whole sheet, large" />
269
+ <span class="detail">{s.name} <span ref={(el) => void (labels.current[k] = el)} /></span>
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ const MOUTH_SAYS: Record<string, string> = { X: 'rest', A: 'M B P', B: 'teeth (S, K)', C: 'EH', D: 'AH', E: 'OH', F: 'OO', G: 'F V', H: 'L' };
278
+
279
+ /** The mouth shapes a character speaks with (cast/<key>/face.json atlas), each cut out with what it says. */
280
+ export function MouthShapes({ slug, who, v }: { slug: string; who: string; v?: number | null }) {
281
+ const [face, setFace] = useState<{ img: HTMLImageElement; views: Array<[string, Record<string, [number, number, number, number]>]>; file: string } | null>(null);
282
+ const [error, setError] = useState<string | null>(null);
283
+ useEffect(() => {
284
+ let gone = false;
285
+ (async () => {
286
+ const info = await json<{ image: string; mouths: Record<string, [number, number, number, number]>; atlases?: Record<string, Record<string, [number, number, number, number]>> }>(fileUrl(slug, `cast/${who}/face.json`, v));
287
+ const file = `cast/${who}/${info.image}`;
288
+ const img = await image(fileUrl(slug, file, v));
289
+ // Mouth patches in atlas pixels: the painting's own (mouths), then one set per sprite view that has its own heads.
290
+ const views: Array<[string, Record<string, [number, number, number, number]>]> = [['painting', info.mouths], ...Object.entries(info.atlases ?? {}).filter(([, m]) => Object.keys(m).length)];
291
+ if (!gone) setFace({ img, file, views: views.filter(([, m]) => m && Object.keys(m).length) });
292
+ })().catch((e) => !gone && setError((e as Error).message));
293
+ return () => void (gone = true);
294
+ }, [slug, who, v]);
295
+ if (!face) return <span class="detail">{error ? `No mouth shapes to show: ${error}` : 'Loading the mouth shapes…'}</span>;
296
+ return (
297
+ <div class="mouths">
298
+ {face.views.map(([view, boxes]) => (
299
+ <div>
300
+ {face.views.length > 1 && <span class="detail">{view}</span>}
301
+ <ul class="sheets">
302
+ {Object.entries(boxes).map(([m, [x, y, w, h]]) => (
303
+ <li key={`${view}-${m}`}>
304
+ <canvas width={w} height={h} style={`width:${Math.min(120, w)}px;height:auto`} ref={(el) => el?.getContext('2d')?.drawImage(face.img, x, y, w, h, 0, 0, w, h)}
305
+ data-lightbox={fileUrl(slug, face.file, v)} title={`${m}: ${MOUTH_SAYS[m] ?? ''}. Click: the whole atlas`} />
306
+ <span class="detail">{m} · {MOUTH_SAYS[m] ?? ''}</span>
307
+ </li>
308
+ ))}
309
+ </ul>
310
+ </div>
311
+ ))}
312
+ </div>
313
+ );
314
+ }
@@ -0,0 +1,93 @@
1
+ Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,7 @@
1
+ /* Fraunces and Instrument Sans, SIL Open Font License 1.1 (OFL.txt), served by Studio itself: its CSP allows no other host. */
2
+ @font-face { font-family: 'Fraunces'; font-style: italic; font-weight: 300 600; font-display: swap; src: url(/fonts/fraunces-italic-latin-ext.woff2) format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; }
3
+ @font-face { font-family: 'Fraunces'; font-style: italic; font-weight: 300 600; font-display: swap; src: url(/fonts/fraunces-italic-latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
4
+ @font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 300 600; font-display: swap; src: url(/fonts/fraunces-normal-latin-ext.woff2) format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; }
5
+ @font-face { font-family: 'Fraunces'; font-style: normal; font-weight: 300 600; font-display: swap; src: url(/fonts/fraunces-normal-latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
6
+ @font-face { font-family: 'Instrument Sans'; font-style: normal; font-weight: 400 600; font-display: swap; src: url(/fonts/instrument-sans-normal-latin-ext.woff2) format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; }
7
+ @font-face { font-family: 'Instrument Sans'; font-style: normal; font-weight: 400 600; font-display: swap; src: url(/fonts/instrument-sans-normal-latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@@ -0,0 +1,42 @@
1
+ // The Studio's signs: small line drawings in the logo's stroke, not emoji. Each takes the text colour; a title on
2
+ // the element around it says what it means.
3
+ import type { JSX } from 'preact';
4
+
5
+ const PATHS = {
6
+ pin: 'M12 21s-6-5.6-6-10.5a6 6 0 0 1 12 0C18 15.4 12 21 12 21Z M12 12.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z',
7
+ zoom: 'M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z M15.3 15.3L20 20 M10.5 8v5 M8 10.5h5',
8
+ pencil: 'M4 20l1-4L15.5 5.5a2.1 2.1 0 0 1 3 3L8 19l-4 1Z M13.5 7.5l3 3',
9
+ mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3Z M6 11a6 6 0 0 0 12 0 M12 17v4',
10
+ camera: 'M4 8h3l2-3h6l2 3h3v11H4Z M12 16.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
11
+ undo: 'M9 7L4 12l5 5 M4 12h10a6 6 0 0 1 0 12',
12
+ repair: 'M20 12a8 8 0 1 1-2.3-5.6 M20 4v5h-5',
13
+ paw: 'M8 10.5a1.8 2.2 0 1 0 0-.1Z M16 10.5a1.8 2.2 0 1 0 0-.1Z M11 7.5a1.6 2 0 1 0 0-.1Z M13 7.5a1.6 2 0 1 0 0-.1Z M12 13c-2.5 0-4.5 2.6-4.5 4.4 0 1.4 1.2 2.1 2.5 1.8 1.3-.3 2.7-.3 4 0 1.3.3 2.5-.4 2.5-1.8 0-1.8-2-4.4-4.5-4.4Z',
14
+ figure: 'M4 5h16v14H4Z M4 16l5-5 4 4 2-2 5 5 M15.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
15
+ bone: 'M8 5.5a2 2 0 1 0-3 2.6 2 2 0 1 0 2.6 3L13 16.4a2 2 0 1 0 3 2.6 2 2 0 1 0-2.6-3L8 10.6',
16
+ cube: 'M12 3l8 4.5v9L12 21l-8-4.5v-9Z M4 7.5l8 4.5 8-4.5 M12 12v9',
17
+ film: 'M4 5h16v14H4Z M8 5v14 M16 5v14 M4 9h4 M4 15h4 M16 9h4 M16 15h4',
18
+ build: 'M4 20h16 M6 20V10l6-5 6 5v10 M10 20v-5h4v5',
19
+ person: 'M12 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M8 21l1-7-2-1 2-4h6l2 4-2 1 1 7',
20
+ book: 'M4 5c3-1 6-1 8 1 2-2 5-2 8-1v14c-3-1-6-1-8 1-2-2-5-2-8-1Z M12 6v14',
21
+ brush: 'M14 4l6 6-7 7-6-6Z M7 11c-2 0-3 1.5-3 3.5 0 2.5-1 4.5-1 4.5s3.5 0 5.5-1.5C10 16 10 14 10 14',
22
+ scissors: 'M6 8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M6 20.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M8 7l12 10 M8 17L20 7',
23
+ ruler: 'M3 17L17 3l4 4L7 21Z M7 13l2 2 M10 10l2 2 M13 7l2 2',
24
+ sound: 'M4 9h4l5-4v14l-5-4H4Z M16.5 8.5a5 5 0 0 1 0 7 M19 6a8.5 8.5 0 0 1 0 12',
25
+ bag: 'M5 8h14l-1 12H6Z M9 8V6a3 3 0 0 1 6 0v2',
26
+ box: 'M3 7.5L12 3l9 4.5v9L12 21l-9-4.5Z M3 7.5L12 12l9-4.5 M12 12v9 M7.5 5.2l9 4.5',
27
+ gear: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1',
28
+ warning: 'M12 4l9 16H3Z M12 10v4 M12 17v.5',
29
+ agent: 'M6 9h12v10H6Z M12 9V5 M12 4.5a1 1 0 1 0 0-.1Z M9.5 13.5v1 M14.5 13.5v1 M3 13v3 M21 13v3',
30
+ hand: 'M9 12V5.5a1.5 1.5 0 0 1 3 0V11 M12 10V4.5a1.5 1.5 0 0 1 3 0V11 M15 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6 7-2.5 0-4-1-5.5-3L4 13.5a1.5 1.5 0 0 1 2.3-2L9 14',
31
+ play: 'M5 9a4 4 0 0 1 4-3h6a4 4 0 0 1 4 3l1.5 6.5a2 2 0 0 1-3.6 1.5L15 15H9l-1.9 2a2 2 0 0 1-3.6-1.5Z M8 9.5v3 M6.5 11h3 M15.5 10.5h.1 M17 12h.1',
32
+ } as const;
33
+
34
+ export type IconName = keyof typeof PATHS;
35
+
36
+ export function Icon({ name, size = 16, ...rest }: { name: IconName; size?: number } & JSX.SVGAttributes<SVGSVGElement>) {
37
+ return (
38
+ <svg class="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={1.6} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" {...rest}>
39
+ <path d={PATHS[name]} />
40
+ </svg>
41
+ );
42
+ }
@@ -0,0 +1,42 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { call } from './api.ts';
3
+ import { Icon } from './icons.tsx';
4
+ import { ReviewSketch } from './review-sketch.tsx';
5
+
6
+ const previews = new Map<string, { task: Promise<string>; image?: string }>();
7
+
8
+ /** Reference drawings are immediate. New concepts paint in the background and never block a choice.
9
+ * The magnifier opens the picture in the shared Lightbox (its click is caught before the card's pick). */
10
+ export function IdeaCard({ label, image, idea, scene, style, styleReference, references, disabled, selected, hidden, onPick }: {
11
+ label: string; image?: string; idea: string; scene: string; style?: string; styleReference?: string; references: string[];
12
+ disabled: boolean; selected?: boolean; hidden?: boolean; onPick: () => void;
13
+ }) {
14
+ const request = JSON.stringify({ idea, scene, ...(style && { style }), ...(styleReference && { styleReference }), references });
15
+ const [preview, setPreview] = useState(image ?? previews.get(request)?.image ?? '');
16
+ const [failed, setFailed] = useState(false);
17
+ useEffect(() => {
18
+ let current = true;
19
+ setPreview(image ?? previews.get(request)?.image ?? ''); setFailed(false);
20
+ if (image) return;
21
+ let task = previews.get(request)?.task;
22
+ if (!task) {
23
+ task = call<{ image: string }>('idea_preview', JSON.parse(request)).then(r => r.image);
24
+ // A long Studio session must not retain every uploaded reference in these keys.
25
+ while (previews.size >= 12) previews.delete(previews.keys().next().value!);
26
+ previews.set(request, { task });
27
+ void task.then(src => { const entry = previews.get(request); if (entry && entry.task === task) entry.image = src; })
28
+ .catch(() => { if (previews.get(request)?.task === task) previews.delete(request); });
29
+ }
30
+ void task.then(src => { if (current) setPreview(src); }).catch(() => { if (current) setFailed(true); });
31
+ return () => { current = false; };
32
+ }, [request, image]);
33
+ return <button type="button" class={`idea-card${selected ? ' selected' : ''}`} aria-label={label} aria-pressed={selected} hidden={hidden} disabled={disabled} onClick={onPick}>
34
+ <span class={`idea-card-picture${!preview && !failed ? ' painting' : ''}`}>
35
+ {preview ? <img src={preview} alt="" /> : <span class="idea-card-placeholder">{failed ? <Icon name="brush" size={25} /> : <ReviewSketch compact />}<small>{failed ? 'Preview unavailable' : 'Sketching…'}</small></span>}
36
+ {preview && <span class="idea-card-zoom" role="button" tabIndex={0} data-lightbox={preview} aria-label={`Enlarge ${label}`} title="See it big. Free"
37
+ onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); e.currentTarget.click(); } }}><Icon name="zoom" size={18} /></span>}
38
+ {selected && <span class="idea-card-mark" aria-hidden="true">✓</span>}
39
+ </span>
40
+ <span class="idea-card-caption"><strong>{label}</strong><small>{selected ? 'Your choice' : image ? 'Your picture' : 'Concept sketch'}</small></span>
41
+ </button>;
42
+ }