@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,168 @@
1
+ // A Gradio app on a Hugging Face Space, called over its HTTP API: upload files, call an endpoint, read
2
+ // its event stream, download the files it answers with. ZeroGPU Spaces count GPU time against the
3
+ // caller's Hugging Face account, so the HF token goes along (only to *.hf.space, Hugging Face's own host).
4
+
5
+ export const HF_TOKEN_VARS = ['HUGGING_FACE_API_KEY', 'HF_TOKEN'] as const;
6
+
7
+ /** HTTP answers of a busy or restarting Space: asking again later can work. */
8
+ export const TRANSIENT = [408, 429, 500, 502, 503, 504];
9
+
10
+ /** How long a call waits: a Space that sends nothing (not even Gradio's heartbeat, every 15 s) for `idleMs`, or
11
+ * has not answered after `totalMs` (a ZeroGPU queue that never moves), is given up on, so the caller can ask
12
+ * again or go on without it instead of a build hanging on one character. Tests shorten them. */
13
+ export const SPACE_WAIT = { idleMs: 180_000, totalMs: 900_000 };
14
+
15
+ /** A file argument, as Gradio expects it after an upload. */
16
+ export type SpaceFile = { path: string; meta: { _type: 'gradio.FileData' }; orig_name?: string };
17
+
18
+ export class Space {
19
+ readonly base: string;
20
+ private readonly headers: Record<string, string>;
21
+
22
+ constructor(readonly id: string) {
23
+ this.base = `https://${id.toLowerCase().replace(/[/_.]/g, '-')}.hf.space/gradio_api`;
24
+ const token = HF_TOKEN_VARS.map((name) => process.env[name]).find(Boolean);
25
+ this.headers = token ? { Authorization: `Bearer ${token}` } : {};
26
+ }
27
+
28
+ async upload(name: string, data: Buffer): Promise<SpaceFile> {
29
+ // Uploading the same bytes again is safe; queue/join can start GPU work and must not be retried blindly.
30
+ for (let attempt = 0; ; attempt++) {
31
+ const form = new FormData();
32
+ form.append('files', new Blob([new Uint8Array(data)]), name);
33
+ const res = await fetch(`${this.base}/upload`, { method: 'POST', headers: this.headers, body: form, signal: AbortSignal.timeout(60_000) });
34
+ if (res.ok) {
35
+ const [where] = (await res.json()) as string[];
36
+ if (typeof where !== 'string' || !where) throw new Error(`${this.id} upload: missing file path`);
37
+ return { path: where, meta: { _type: 'gradio.FileData' }, orig_name: name };
38
+ }
39
+ // Do not put proxy HTML in the build feed. The operation and status are enough to diagnose it.
40
+ await res.body?.cancel();
41
+ if (!TRANSIENT.includes(res.status) || attempt === 2) {
42
+ throw new Error(`${this.id} upload: HTTP ${res.status} (${attempt + 1} attempt(s))`);
43
+ }
44
+ const retryAfter = res.headers.get('retry-after');
45
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
46
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : retryAfter ? Date.parse(retryAfter) - Date.now() : 0;
47
+ await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, Math.max(1000 * 2 ** attempt, Number.isFinite(delay) ? delay : 0))));
48
+ }
49
+ }
50
+
51
+ /** One session for every call, so endpoints that keep files per session (TRELLIS) find them. */
52
+ private readonly session = Math.random().toString(36).slice(2, 13);
53
+ private endpoints?: Map<string, { fn: number; states: number[] }>;
54
+
55
+ /** Calls an endpoint; returns the data of every event it sent (a generator sends several), in order. */
56
+ async call(api: string, data: unknown[]): Promise<unknown[][]> {
57
+ const { fn, states } = await this.endpoint(api);
58
+ // The API leaves out the app's hidden state inputs; the queue wants them in place, as null.
59
+ data = [...data];
60
+ for (const at of states) data.splice(at, 0, null);
61
+ const join = await fetch(`${this.base}/queue/join`, {
62
+ method: 'POST',
63
+ headers: { ...this.headers, 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ data, fn_index: fn, session_hash: this.session, event_data: null, trigger_id: null }),
65
+ signal: AbortSignal.timeout(60_000),
66
+ });
67
+ if (!join.ok) throw new Error(`${this.id} /${api}: ${join.status} ${(await join.text()).slice(0, 200)}`);
68
+ const { event_id } = (await join.json()) as { event_id: string };
69
+ const started = Date.now();
70
+ const stop = new AbortController();
71
+ let quiet: ReturnType<typeof setTimeout> | undefined;
72
+ let why = '';
73
+ const wait = () => {
74
+ clearTimeout(quiet);
75
+ const ms = Math.min(SPACE_WAIT.idleMs, SPACE_WAIT.totalMs - (Date.now() - started));
76
+ quiet = setTimeout(() => {
77
+ why = Date.now() - started >= SPACE_WAIT.totalMs ? `no answer after ${Math.round(SPACE_WAIT.totalMs / 1000)} s` : `silent for ${Math.round(SPACE_WAIT.idleMs / 1000)} s`;
78
+ stop.abort();
79
+ }, Math.max(0, ms));
80
+ };
81
+ wait();
82
+ try {
83
+ return await this.listen(api, event_id, stop.signal, wait);
84
+ } catch (error) {
85
+ if (why) throw new Error(`${this.id} /${api}: ${why}`);
86
+ throw error;
87
+ } finally {
88
+ clearTimeout(quiet);
89
+ stop.abort();
90
+ }
91
+ }
92
+
93
+ /** Reads the queue's event stream until the call's own answer; `heard` is called on every chunk. */
94
+ private async listen(api: string, event_id: string, signal: AbortSignal, heard: () => void): Promise<unknown[][]> {
95
+ const stream = await fetch(`${this.base}/queue/data?session_hash=${this.session}`, { headers: this.headers, signal });
96
+ if (!stream.ok || !stream.body) throw new Error(`${this.id} /${api}: queue ${stream.status}`);
97
+ const events: unknown[][] = [];
98
+ const decoder = new TextDecoder();
99
+ let buffered = '';
100
+ for await (const chunk of stream.body as unknown as AsyncIterable<Uint8Array>) {
101
+ heard();
102
+ buffered += decoder.decode(chunk, { stream: true });
103
+ const lines = buffered.split('\n');
104
+ buffered = lines.pop() ?? '';
105
+ for (const line of lines) {
106
+ if (!line.startsWith('data:')) continue;
107
+ if (process.env.DIORAMA_SPACE_DEBUG) console.error(line.slice(0, 400));
108
+ const msg = JSON.parse(line.slice(5)) as { msg: string; event_id?: string; success?: boolean; output?: { data?: unknown[]; error?: unknown; title?: string } };
109
+ if (msg.event_id && msg.event_id !== event_id) continue;
110
+ if (msg.msg === 'process_generating' && msg.output?.data) events.push(msg.output.data);
111
+ if (msg.msg === 'process_completed') {
112
+ if (!msg.success || msg.output?.error !== undefined && msg.output.error !== null) {
113
+ throw new Error(`${this.id} /${api}: ${JSON.stringify(msg.output?.error ?? msg.output).slice(0, 300)}`);
114
+ }
115
+ if (msg.output?.data) events.push(msg.output.data);
116
+ stream.body.cancel().catch(() => {});
117
+ return events;
118
+ }
119
+ }
120
+ }
121
+ throw new Error(`${this.id} /${api}: the stream closed before an answer`);
122
+ }
123
+
124
+ /** The fn_index of a named endpoint and where its hidden state inputs go, from the app's config. */
125
+ private async endpoint(api: string): Promise<{ fn: number; states: number[] }> {
126
+ if (!this.endpoints) {
127
+ const res = await fetch(`${this.base.replace(/\/gradio_api$/, '')}/config`, { headers: this.headers, signal: AbortSignal.timeout(60_000) });
128
+ if (!res.ok) throw new Error(`${this.id} config: ${res.status}`);
129
+ type Config = { components: { id: number; type: string }[]; dependencies: { id?: number; api_name?: string | false; inputs: number[] }[] };
130
+ const config = (await res.json()) as Config;
131
+ const state = new Set(config.components.filter((c) => c.type === 'state').map((c) => c.id));
132
+ this.endpoints = new Map(config.dependencies.flatMap((d, i) => d.api_name
133
+ ? [[d.api_name, { fn: d.id ?? i, states: d.inputs.flatMap((c, at) => (state.has(c) ? [at] : [])) }] as const]
134
+ : []));
135
+ }
136
+ const found = this.endpoints.get(api);
137
+ if (!found) throw new Error(`${this.id} has no endpoint /${api}`);
138
+ return found;
139
+ }
140
+
141
+ async download(file: { path: string; url?: string }): Promise<Buffer> {
142
+ // A download only reads a file the Space already made, so a busy or restarting Space is asked again.
143
+ for (let attempt = 0; ; attempt++) {
144
+ const res = await fetch(file.url ?? `${this.base}/file=${file.path}`, { headers: this.headers, signal: AbortSignal.timeout(120_000) }).catch((error: Error) => error);
145
+ if (!(res instanceof Error) && res.ok) return Buffer.from(await res.arrayBuffer());
146
+ if (!(res instanceof Error)) await res.body?.cancel();
147
+ const status = res instanceof Error ? res.message : res.status;
148
+ if ((!(res instanceof Error) && !TRANSIENT.includes(res.status)) || attempt === 3) {
149
+ throw new Error(`${this.id} download ${file.path}: ${status}${attempt ? ` (${attempt + 1} attempts)` : ''}`);
150
+ }
151
+ await new Promise((resolve) => setTimeout(resolve, 2000 * 2 ** attempt));
152
+ }
153
+ }
154
+ }
155
+
156
+ /** Every file in an endpoint's events whose path ends with `suffix`, the last one sent first. */
157
+ export function filesNamed(events: unknown[][], suffix: string): { path: string; url?: string }[] {
158
+ const found: { path: string; url?: string }[] = [];
159
+ const visit = (v: unknown): void => {
160
+ if (!v || typeof v !== 'object') return;
161
+ if (Array.isArray(v)) return v.forEach(visit);
162
+ const o = v as Record<string, unknown>;
163
+ if (typeof o.path === 'string' && o.path.endsWith(suffix)) found.push({ path: o.path, url: typeof o.url === 'string' ? o.url : undefined });
164
+ Object.values(o).forEach(visit);
165
+ };
166
+ events.forEach(visit);
167
+ return found.reverse();
168
+ }
@@ -0,0 +1,293 @@
1
+ // Walk-cycle sprites of a figure: the rigged model (cast/<key>/model/) walks in a grid of frames for one
2
+ // facing, Muse draws that sheet again by hand in the world's painted style, keeping every pose and
3
+ // position, and each painted frame is cut out and lined up with the frame it was drawn over. The
4
+ // model gives the poses; everything seen in the game is Muse's.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { build } from 'esbuild';
9
+ import sharp from 'sharp';
10
+ import type { AnimationSettings } from './animation.ts';
11
+ import { chromium } from '../commands/explore.ts';
12
+ import { museEdit } from '../meta/muse.ts';
13
+ import { askSparkJson } from '../meta/spark.ts';
14
+ import type { Memo } from '../meta/memo.ts';
15
+ import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
16
+ import { components, dilate, maskBox, maskIou } from '../image/raster.ts';
17
+
18
+ const WEB = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../web');
19
+
20
+ /** The five facings drawn; the three facing left are these mirrored. yaw 0 faces the viewer, PI/2 screen right. */
21
+
22
+ /** Below this mean pose IoU a walk take has frames facing the wrong way or legs that hardly walk. */
23
+ export const POSE_OK = 0.85;
24
+ export const FACINGS = { front: 0, 'front-right': Math.PI / 4, right: Math.PI / 2, 'back-right': (3 * Math.PI) / 4, back: Math.PI } as const;
25
+ export type Facing = keyof typeof FACINGS;
26
+
27
+ /** One sheet: 8 frames of a full walk cycle in 4 columns and 2 rows, Muse's own size. */
28
+ export const SHEET = { cols: 4, rows: 2, width: 1200, height: 896, feetAt: 0.95, px: 385, elevation: 0.18 } as const;
29
+ /** One extra cell is reserved for standing; talk uses the same cell dimensions and grid. */
30
+ export function modelSheet(frames: number) {
31
+ if (![8, 16, 32].includes(frames)) throw new Error('Model sprites require 8, 16 or 32 walk frames');
32
+ const rows = Math.ceil((frames + 1) / SHEET.cols);
33
+ // Keep painted 8-cell guides unchanged. Direct UV sheets have no provider size limit:
34
+ // render each cell at 1.5x in both axes to retain finer facial and hand detail.
35
+ const scale = 1.5;
36
+ return { ...SHEET, rows, frames, width: SHEET.width * scale, height: rows * (SHEET.height / SHEET.rows) * scale, px: SHEET.px * scale };
37
+ }
38
+
39
+ const GREY = { r: 217, g: 217, b: 217 };
40
+
41
+ type RoutePage = {
42
+ route(url: string, fn: (route: { request(): { url(): string }; fulfill(o: { status?: number; contentType?: string; body: string | Buffer }): Promise<void> }) => unknown): Promise<void>;
43
+ goto(url: string): Promise<unknown>;
44
+ evaluate<T>(js: string): Promise<T>;
45
+ on(event: 'pageerror', fn: (e: { message: string }) => void): void;
46
+ };
47
+
48
+ /**
49
+ * Mean `same` (cutSheet) above this means Muse gave the 3D guide back with a filter over it instead of
50
+ * drawing it again. On foghorn's walk sheets, sheets drawn again scored 0.22-0.37 and traced ones 0.60-0.78;
51
+ * pose IoU did not tell them apart (a drawn sheet can follow the pose as closely as a traced one).
52
+ */
53
+ export const TRACED = 0.5;
54
+
55
+ /**
56
+ * A walk cell whose painted figure is shorter than this share of the sheet's usual one is not a step (a
57
+ * crouch, a sit). Walk frames vary by under 6% (5 worlds, 18 sheets); lamp-quarter's crouches were 0.73-0.78.
58
+ */
59
+ export const OFF_GAIT = 0.88;
60
+
61
+ /**
62
+ * The model walking (or, given `gestures`, standing in those talking poses of src/play/gestures.ts, one
63
+ * per cell), one transparent sheet per facing, rendered in headless Chromium with WebGL.
64
+ */
65
+ export async function renderGuides(modelDir: string, facings: Facing[], gestures?: number[], /** grey clay, no textures */ plain = false, animation?: AnimationSettings, /** model.json fields to override */ over: Record<string, unknown> = {}): Promise<Record<string, Buffer>> {
66
+ const info = { ...JSON.parse(fs.readFileSync(path.join(modelDir, 'model.json'), 'utf8')), ...over };
67
+ if (animation) {
68
+ info.texture = animation.texture;
69
+ if (animation.coatCorrection !== undefined) info.coatCorrection = animation.coatCorrection;
70
+ }
71
+ const js = (await build({ entryPoints: [path.join(WEB, 'sheet.ts')], bundle: true, format: 'esm', write: false, target: 'es2022' })).outputFiles[0].text;
72
+ const browser = await (await chromium()).launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
73
+ try {
74
+ const page = (await browser.newPage()) as unknown as RoutePage;
75
+ const errors: string[] = [];
76
+ page.on('pageerror', (e) => errors.push(e.message));
77
+ await page.route('http://sprites.local/**', async (route) => {
78
+ const file = new URL(route.request().url()).pathname.slice(1);
79
+ if (!file) return route.fulfill({ contentType: 'text/html', body: `<!doctype html><script type="module">${js}</script>` });
80
+ const at = path.join(modelDir, path.basename(file));
81
+ if (!fs.existsSync(at)) return route.fulfill({ status: 404, body: '' });
82
+ return route.fulfill({ contentType: file.endsWith('.png') ? 'image/png' : 'application/octet-stream', body: fs.readFileSync(at) });
83
+ });
84
+ await page.goto('http://sprites.local/');
85
+ const out: Record<string, Buffer> = {};
86
+ const direct = animation?.texture === 'model';
87
+ for (const facing of facings) {
88
+ const ask = { info, base: 'http://sprites.local', yaw: FACINGS[facing], gestures, plain, ...SHEET, ...(direct && modelSheet(animation.frames)), stand: direct && !gestures };
89
+ const url = await page.evaluate<string>(`window.renderSheet(${JSON.stringify(ask)})`);
90
+ if (errors.length) throw new Error(`sheet page: ${errors.join('; ')}`);
91
+ out[facing] = Buffer.from(url.split(',')[1], 'base64');
92
+ }
93
+ return out;
94
+ } finally {
95
+ await browser.close();
96
+ }
97
+ }
98
+
99
+ /** The guide on Muse's plain grey, which is what it is asked to keep. */
100
+ export const onGrey = (guide: Buffer) => sharp(guide).flatten({ background: GREY }).png().toBuffer();
101
+
102
+ /** The guide as flat colour blocks (median filter, 8 colours): the render's shading is gone, nothing to trace. */
103
+ export const flatGuide = async (grey: Buffer) =>
104
+ sharp(await sharp(grey).median(9).png().toBuffer()).png({ palette: true, colours: 8, dither: 0 }).toBuffer();
105
+
106
+ const SEEN: Record<Facing, string> = {
107
+ front: 'walking towards the viewer, face to the viewer',
108
+ 'front-right': 'walking diagonally towards the viewer and to the right of the picture (three-quarter view), face visible',
109
+ right: 'walking to the right of the picture, seen exactly from the side in profile',
110
+ 'back-right': 'walking diagonally away from the viewer and to the right of the picture, seen three-quarters from behind: back of the head, hardly any face',
111
+ back: 'walking straight away from the viewer, seen from behind: back of the head and back of the clothes, no face',
112
+ };
113
+
114
+ /**
115
+ * Muse draws the sheet again from scratch over the guide. `refs`: the character (Muse's painted view for
116
+ * that side) and, for consistency across facings, a sheet already painted. `style`: the world's style, named
117
+ * once the world was given another one than it was made in (style_set); else the painterly gouache it was made for.
118
+ */
119
+ export function paintSheet(memo: Memo, key: string, facing: Facing, looks: string, guide: Buffer, refs: Buffer[], take = 0, talkFrames = 0, style?: string): Promise<Buffer> {
120
+ const talk = talkFrames > 0; // the sheet is always full: Muse redraws a half-empty grid as a few big figures
121
+ const what = talk
122
+ ? `a talking sprite sheet: eight frames of one character standing still and talking, ${SEEN[facing].replace(/^walking/, 'turned')}, gesturing with empty hands (holding nothing) and the head, mouth slightly open in some frames; where the guide's coat bulges around a hand, it is only the hand gesture`
123
+ : `a walk-cycle sprite sheet: eight frames of one character ${SEEN[facing]}`;
124
+ return museEdit(memo, {
125
+ label: `${key}-${talk ? 'talk' : 'sprites'}-${facing}`, take, size: `${SHEET.width}x${SHEET.height}`, images: [guide, ...refs],
126
+ prompt: `The first image is only a rough 3D pose guide for ${what}. ` +
127
+ `Draw this sheet again from scratch as a hand-painted 2D game sprite sheet of the character in the other pictures (${looks}): same face, hair, headwear, clothes, patches, boots and colours, ${style ? `in their style: ${style}` : 'in their painterly gouache style'}. ` +
128
+ `Each frame: exactly the same position, size, facing and body pose as the guide (same leg, arm and hand positions), but clean anatomy: one face, natural hands, natural boots that keep their shape, smooth readable clothing folds, no patchy textures, no 3D or CG look. ` +
129
+ // A thing the model's views left standing at the feet is fused to a boot in the guide: a plank or a case swinging with the leg.
130
+ 'Anything in the guide that lies on the ground or sticks out flat from the feet (a slab, a plank, a box, a case) is not part of the character: leave it out. ' +
131
+ 'Plain flat light grey background, no ground shadows, nothing on the ground, nothing else.',
132
+ });
133
+ }
134
+
135
+ type Rgba = { data: Buffer; width: number; height: number };
136
+ const rgba = async (png: Buffer, size?: [number, number]): Promise<Rgba> => {
137
+ let img = sharp(png).ensureAlpha();
138
+ if (size) img = img.resize(size[0], size[1], { fit: 'fill' });
139
+ const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
140
+ return { data, width: info.width, height: info.height };
141
+ };
142
+
143
+ /** Background of a painted cell: pixels joined to its border that are close to the border's median colour. */
144
+ function background(img: Rgba, [x0, y0, w, h]: number[], tolerance = 26): Mask {
145
+ const px = (x: number, y: number) => ((y0 + y) * img.width + x0 + x) * 4;
146
+ const border: number[][] = [];
147
+ for (let x = 0; x < w; x++) for (const y of [0, h - 1]) border.push([...img.data.subarray(px(x, y), px(x, y) + 3)]);
148
+ for (let y = 0; y < h; y++) for (const x of [0, w - 1]) border.push([...img.data.subarray(px(x, y), px(x, y) + 3)]);
149
+ const med = [0, 1, 2].map((c) => border.map((p) => p[c]).sort((a, b) => a - b)[border.length >> 1]);
150
+ const near = (i: number) => {
151
+ const o = px(i % w, Math.floor(i / w));
152
+ return Math.hypot(img.data[o] - med[0], img.data[o + 1] - med[1], img.data[o + 2] - med[2]) < tolerance;
153
+ };
154
+ const bg = emptyMask(w, h);
155
+ const stack: number[] = [];
156
+ const push = (i: number) => {
157
+ if (!bg.bits[i] && near(i)) { bg.bits[i] = 1; stack.push(i); }
158
+ };
159
+ for (let x = 0; x < w; x++) { push(x); push((h - 1) * w + x); }
160
+ for (let y = 0; y < h; y++) { push(y * w); push(y * w + w - 1); }
161
+ while (stack.length) {
162
+ const i = stack.pop()!;
163
+ const x = i % w;
164
+ if (x > 0) push(i - 1);
165
+ if (x < w - 1) push(i + 1);
166
+ if (i >= w) push(i - w);
167
+ if (i < w * (h - 1)) push(i + w);
168
+ }
169
+ return bg;
170
+ }
171
+
172
+ /**
173
+ * How closely the drawn figure keeps the guide's own shading: the correlation of the two images' edge
174
+ * strength (Sobel on luminance) inside both figures, away from the outline. A sheet drawn again has its
175
+ * own folds, hair and face, about 0.2-0.4; the guide with a filter over it keeps the render's edges, 0.6+.
176
+ * Colour alone does not tell them apart: Muse repaints a traced figure in other colours just as often.
177
+ */
178
+ function sameEdges(guide: Buffer, drawn: Buffer, W: number, [x0, y0, cw, ch]: number[]): number {
179
+ const lum = (d: Buffer, x: number, y: number) => { const i = ((y0 + y) * W + x0 + x) * 4; return 0.3 * d[i] + 0.59 * d[i + 1] + 0.11 * d[i + 2]; };
180
+ const edge = (d: Buffer, x: number, y: number) => {
181
+ const p = (dx: number, dy: number) => lum(d, x + dx, y + dy);
182
+ const gx = p(1, -1) + 2 * p(1, 0) + p(1, 1) - p(-1, -1) - 2 * p(-1, 0) - p(-1, 1);
183
+ const gy = p(-1, 1) + 2 * p(0, 1) + p(1, 1) - p(-1, -1) - 2 * p(0, -1) - p(1, -1);
184
+ return Math.hypot(gx, gy);
185
+ };
186
+ const inside = (x: number, y: number) => {
187
+ const i = ((y0 + y) * W + x0 + x) * 4 + 3;
188
+ return guide[i] > 40 && drawn[i] > 0;
189
+ };
190
+ const a: number[] = [];
191
+ const b: number[] = [];
192
+ for (let y = 4; y < ch - 4; y++) for (let x = 4; x < cw - 4; x++) {
193
+ if (!inside(x, y) || !inside(x + 3, y) || !inside(x - 3, y) || !inside(x, y + 3) || !inside(x, y - 3)) continue;
194
+ a.push(edge(guide, x, y));
195
+ b.push(edge(drawn, x, y));
196
+ }
197
+ if (a.length < 50) return 0;
198
+ const [ma, mb] = [a.reduce((s, v) => s + v, 0) / a.length, b.reduce((s, v) => s + v, 0) / b.length];
199
+ let [ab, aa, bb] = [0, 0, 0];
200
+ for (let i = 0; i < a.length; i++) { ab += (a[i] - ma) * (b[i] - mb); aa += (a[i] - ma) ** 2; bb += (b[i] - mb) ** 2; }
201
+ return aa && bb ? ab / Math.sqrt(aa * bb) : 0;
202
+ }
203
+
204
+ /**
205
+ * Cuts each painted frame out of its grey and lines it up with its guide frame: same feet line, same
206
+ * centre, same height. Returns the transparent sheet and how well each frame kept the guide's pose (IoU)
207
+ * and its look (`same`, sameEdges).
208
+ */
209
+ export async function cutSheet(guidePng: Buffer, paintedPng: Buffer): Promise<{ sheet: Buffer; iou: number[]; legs: number[]; same: number[]; stand: number; off: number[] }> {
210
+ const { cols, rows, width: W, height: H } = SHEET;
211
+ const guide = await rgba(guidePng, [W, H]);
212
+ const painted = await rgba(paintedPng, [W, H]);
213
+ const out = Buffer.alloc(W * H * 4);
214
+ const [cw, ch] = [W / cols, H / rows];
215
+ const iou: number[] = [];
216
+ const legs: number[] = [];
217
+ const same: number[] = [];
218
+ const spread: number[] = [];
219
+ const tall: number[] = [];
220
+ for (let i = 0; i < cols * rows; i++) {
221
+ const cell = [(i % cols) * cw, Math.floor(i / cols) * ch, cw, ch];
222
+ const [x0, y0] = cell;
223
+ const want = emptyMask(cw, ch);
224
+ for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) want.bits[y * cw + x] = guide.data[((y0 + y) * W + x0 + x) * 4 + 3] > 40 ? 1 : 0;
225
+ const bg = background(painted, cell);
226
+ const fig = emptyMask(cw, ch);
227
+ for (let k = 0; k < fig.bits.length; k++) fig.bits[k] = 1 - bg.bits[k];
228
+ // Keep blobs that overlap where the guide figure is (a stray speck or a neighbour's boot does not).
229
+ const near = dilate(want, 14);
230
+ const kept = emptyMask(cw, ch);
231
+ for (const blob of components(fig)) {
232
+ if (maskArea(blob) < 30 || !blob.bits.some((b, k) => b && near.bits[k])) continue;
233
+ blob.bits.forEach((b, k) => { if (b) kept.bits[k] = 1; });
234
+ }
235
+ const gb = maskBox(want);
236
+ const pb = maskBox(kept);
237
+ if (!gb || !pb) { iou.push(0); legs.push(0); same.push(0); spread.push(Infinity); tall.push(0); continue; }
238
+ tall.push(pb[3] - pb[1]);
239
+ // Same height, same feet line, same centre across.
240
+ const s = Math.min(1.2, Math.max(0.8, (gb[3] - gb[1]) / (pb[3] - pb[1])));
241
+ const [gcx, pcx] = [(gb[0] + gb[2]) / 2, (pb[0] + pb[2]) / 2];
242
+ const moved = emptyMask(cw, ch);
243
+ for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) {
244
+ const sx = Math.round((x - gcx) / s + pcx);
245
+ const sy = Math.round((y - gb[3]) / s + pb[3]);
246
+ if (sx < 0 || sy < 0 || sx >= cw || sy >= ch || !kept.bits[sy * cw + sx]) continue;
247
+ moved.bits[y * cw + x] = 1;
248
+ const from = ((y0 + sy) * W + x0 + sx) * 4;
249
+ const to = ((y0 + y) * W + x0 + x) * 4;
250
+ painted.data.copy(out, to, from, from + 3);
251
+ out[to + 3] = 255;
252
+ }
253
+ same.push(Number(sameEdges(guide.data, out, W, cell).toFixed(3)));
254
+ iou.push(Number(maskIou(want, moved).toFixed(3)));
255
+ // Muse sometimes draws the other leg forward: the legs alone (lowest third) catch that.
256
+ const below = Math.round(gb[3] - (gb[3] - gb[1]) / 3);
257
+ const low = (m: Mask) => ({ ...m, bits: m.bits.map((b, k) => (k >= below * cw ? b : 0)) });
258
+ legs.push(Number(maskIou(low(want), low(moved)).toFixed(3)));
259
+ const feet = maskBox(low(want));
260
+ spread.push(feet ? feet[2] - feet[0] : Infinity);
261
+ }
262
+ // Muse now and then paints one frame of a walk as a crouch (lamp-quarter: the courier set down his lantern
263
+ // in the last cell of 3 of 4 sheets). Each cell is scaled to the guide's height above, so the cut sheet
264
+ // hides it; the painted figure, a fifth shorter than the rest, does not. A walk steps over such a cell.
265
+ const usual = [...tall].sort((a, b) => a - b)[Math.floor(tall.length / 2)];
266
+ const off = tall.flatMap((t, i) => (t < OFF_GAIT * usual ? [i] : []));
267
+ // Standing still shows the frame whose feet are closest together (the legs passing each other).
268
+ const stand = spread.reduce((best, s, i) => (!off.includes(i) && (best < 0 || s < spread[best]) ? i : best), -1);
269
+ return { sheet: await sharp(out, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer(), iou, legs, same, stand: Math.max(0, stand), off };
270
+ }
271
+
272
+
273
+ /**
274
+ * Whether the model wears the front painting on its face (web/hero3d.ts `face: 'views'`) instead of its own
275
+ * UV texture. TRELLIS often bakes the face to a blank smudge or a dark band over the eyes; the painting then
276
+ * gives the face Muse drew. But the painting is fitted only by silhouette and can land a second pair of eyes
277
+ * beside the model's, so Spark compares the two heads, as the sprites will show them, and picks.
278
+ */
279
+ export async function uvFaceLost(memo: Memo, key: string, modelDir: string, animation: AnimationSettings): Promise<boolean> {
280
+ const head = async (face: 'views' | undefined) => {
281
+ const render = (await renderGuides(modelDir, ['front'], [0], false, animation, { face })).front;
282
+ const trimmed = await sharp(render).trim().png().toBuffer({ resolveWithObject: true });
283
+ return sharp(trimmed.data).extract({ left: 0, top: 0, width: trimmed.info.width, height: Math.max(1, Math.round(trimmed.info.height * 0.2)) })
284
+ .flatten({ background: GREY }).resize({ width: 512 }).png().toBuffer();
285
+ };
286
+ const a = await askSparkJson<{ why?: unknown; better?: unknown }>(memo, {
287
+ label: `uv-face:${key}`, images: [await head(undefined), await head('views'), fs.readFileSync(path.join(modelDir, 'front.png'))],
288
+ prompt: 'Pictures 1 and 2 are the same 3D game character\'s head, rendered two ways; picture 3 is the painting of that character. Which render has the better face: '
289
+ + 'two clear, separate eyes in the right place, one nose, one mouth, closest to the painting? A face with no eyes, a dark band or smudge over the eyes, '
290
+ + 'or eyes doubled, shifted or cut by a seam is worse. Reply with JSON only: {"why": "a few words", "better": 1 or 2}.',
291
+ });
292
+ return Number(a.better) === 2;
293
+ }
@@ -0,0 +1,163 @@
1
+ // Muse paints the hero from four sides, standing still: the front view is what TRELLIS turns into a
2
+ // model, and all four are projected back onto it, so the walking model looks painted from every side.
3
+ import sharp from 'sharp';
4
+ import { museEdit } from '../meta/muse.ts';
5
+ import type { Memo } from '../meta/memo.ts';
6
+ import type { ViewName } from './fit.ts';
7
+ import { askSparkJson } from '../meta/spark.ts';
8
+ import { refusedByFilter } from '../scene/words.ts';
9
+
10
+ const SIZE = '1024x1024';
11
+ /** Each view as it lands, so Studio shows them one by one while the rest are painted. */
12
+ type Landed = (name: ViewName, png: Buffer) => void;
13
+ const STAND = 'standing still in a clear rigging A-pose: both straight arms extended diagonally outwards 35 degrees from vertical, elbows straight, hands at least one hand-width away from the thighs, a wide uninterrupted background gap between each arm and the torso and hips, legs straight and shoulder-width apart, feet flat';
14
+ // The arms in every view where the front has them: TRELLIS builds one body from all four, and each view is
15
+ // projected back onto it, so a hand in a pocket in one view is painted on the hip as a second pair of hands.
16
+ // Looks often say "hand in pocket"; this pose wins over it.
17
+ // Empty hands make Muse put down what the painting had in them: a suitcase standing at the feet in one view
18
+ // becomes a slab TRELLIS fuses to a boot, and it swings with the leg in every sprite. So it is left out, not put down.
19
+ // A held flag left "out of the hands" ends up waving behind the figure; TRELLIS fuses it to the back as a
20
+ // mangled sheet and the auto-rig skins it to the spine, so a held pole or flag is named and banned outright.
21
+ const HANDS = 'Both hands empty and fully visible, well separated from the thighs, never touching clothing: not in pockets, not on the hips, not holding anything, not in front of the body. Whatever the description has in their hands (a suitcase, a basket, a tool) is left out of the picture altogether, never put down on the ground beside them. ' +
22
+ 'A flag, banner, pole, staff, spear, broom, umbrella, sign or lantern they held is left out too: not behind them, not over the shoulder, not on the back, nowhere in the picture.';
23
+ const SAME_HEAD = 'Whatever is on the head (hood up or down, hat, hair) is exactly as in the front picture, from this side too.';
24
+ // Looks written from a photo may still say where the person sat or lay ("against a headboard"): a profile then
25
+ // paints the headboard behind them, and TRELLIS grows it into the body as a slab around the model.
26
+ const PLAIN = 'Full body from head to feet, centred, nothing cropped. Standing free: nothing behind or beside them, no bed, headboard, chair, wall or pillow, whatever the description says about where they were. Plain flat light grey background, soft even light, no shadow on the ground, no other objects: nothing standing or lying on the ground, nothing at their feet.';
27
+
28
+ /** Where a carried thing sits on the body, from the character's own point of view. */
29
+ export type Spot = 'front' | 'back' | 'left' | 'right';
30
+ /** Something the character wears or carries that is not plain clothing: a bag, a satchel, a lantern, a sword. */
31
+ export type Carried = { name: string; at: Spot; strap?: string };
32
+
33
+ /**
34
+ * Where a thing at `at` (the character's own front, back, left or right) shows in a view. Views are straight on:
35
+ * front sees the character's left at the picture's right; the "left" profile faces the picture's left and shows
36
+ * the character's left side (src/model/fit.ts axes).
37
+ */
38
+ export function spotIn(view: ViewName, at: Spot): string {
39
+ const table: Record<ViewName, Record<Spot, string>> = {
40
+ front: { front: 'on the front of the body, in the middle', back: 'behind the body, hidden (only a strap may show)', left: 'at the right edge of the figure in the picture (the character\'s left side)', right: 'at the left edge of the figure in the picture (the character\'s right side)' },
41
+ back: { front: 'in front of the body, hidden (only a strap may show over the shoulder or round the side)', back: 'on the back, in the middle, fully visible', left: 'at the LEFT edge of the figure in the picture (the character\'s left side, seen from behind)', right: 'at the RIGHT edge of the figure in the picture (the character\'s right side, seen from behind)' },
42
+ left: { front: 'in front of the body, at the LEFT edge of the figure, where the character faces', back: 'on the back, at the RIGHT edge of the figure, behind the character', left: 'on the near side, fully visible against the body', right: 'on the far side, hidden behind the body (only a strap may show)' },
43
+ right: { front: 'in front of the body, at the RIGHT edge of the figure, where the character faces', back: 'on the back, at the LEFT edge of the figure, behind the character', left: 'on the far side, hidden behind the body (only a strap may show)', right: 'on the near side, fully visible against the body' },
44
+ };
45
+ return table[view][at];
46
+ }
47
+
48
+ /** What the character carries and where, read once off the front view: the other views are told exactly where each thing goes. */
49
+ export async function readCarried(memo: Memo, key: string, front: Buffer): Promise<Carried[]> {
50
+ const a = await askSparkJson<{ items?: { name?: unknown; at?: unknown; strap?: unknown }[] }>(memo, {
51
+ label: `views-carried:${key}`, images: [front],
52
+ prompt: 'This character is seen straight from the front (their left hand is on the right of the picture). List what they wear on the body that is not plain clothing and that could end up on the wrong side in a drawing from another side: bags, satchels, backpacks, pouches, a sword in a belt, a quiver, a scarf end, a badge. '
53
+ + 'Only things fastened to the body (a strap, a belt, a hook, worn): never something held in a hand or held up, and never a flag, banner, pole, staff, umbrella or lantern, even when one shows behind them (those do not belong in this picture). '
54
+ + 'For each: where its main part sits on the body from the character\'s own point of view, one of "front", "back", "left", "right" (a bag hanging at their left hip is "left", one on their belly is "front"), and the strap if any (e.g. "over the right shoulder, across the chest"). '
55
+ + 'Reply with JSON only: {"items": [{"name": "brown leather satchel", "at": "front", "strap": "over the right shoulder, across the chest"}]} or {"items": []}.',
56
+ });
57
+ const spots = ['front', 'back', 'left', 'right'];
58
+ return (a.items ?? []).flatMap((i) => (typeof i.name === 'string' && spots.includes(String(i.at)) ? [{ name: i.name, at: i.at as Spot, ...(typeof i.strap === 'string' && i.strap && { strap: i.strap }) }] : []));
59
+ }
60
+
61
+ /** The sentence a view's prompt gets: each carried thing, where it shows in this view. */
62
+ export function carriedLine(view: ViewName, items: Carried[]): string {
63
+ if (!items.length) return '';
64
+ return `What they carry stays where it is on the body, it does not move round when we walk round them: ${items.map((i) => `the ${i.name}${i.strap ? ` (strap ${i.strap})` : ''} is ${spotIn(view, i.at)}`).join('; ')}.`;
65
+ }
66
+
67
+ /** A view at check size: where a bag hangs reads at 640 px; four full 1024 px views cost four times the tokens. */
68
+ const small = (png: Buffer) => sharp(png).resize(640, 640, { fit: 'inside' }).png().toBuffer();
69
+
70
+ /** The views that show a carried thing in the wrong place, or something on the ground at the feet, with why. */
71
+ export async function checkCarried(memo: Memo, key: string, views: Record<ViewName, Buffer>, items: Carried[], round = 0): Promise<Partial<Record<ViewName, string>>> {
72
+ const names: ViewName[] = ['front', 'back', 'left', 'right'];
73
+ const expect = items.length ? names.map((v, n) => `Picture ${n + 1} (${v} view): ${items.map((i) => `the ${i.name} should be ${spotIn(v, i.at)}`).join('; ')}.`).join('\n') : 'They carry nothing that is not plain clothing.';
74
+ const a = await askSparkJson<Record<string, { ok?: unknown; why?: unknown }>>(memo, {
75
+ label: `views-carried-check:${key}#${round}`, images: await Promise.all(names.map((v) => small(views[v]))),
76
+ prompt: `Four pictures of one character, seen from the front, back, their left side (facing the picture's left) and their right side (facing the picture's right). Check only where the carried things are, and that nothing stands or lies on the ground; ignore everything else.\n${expect}\n\n`
77
+ + 'A thing that shows on the wrong side of the body, on the front when it should be on the back (or the other way round), twice, or missing where it should be fully visible, is wrong. '
78
+ + 'Anything standing or lying on the ground, at the feet or beside them (a suitcase, a bag, a box, a plank), or held in a hand, is wrong in every view, the front too; so is a flag, banner, pole, staff or umbrella anywhere in the picture, behind the figure or over the shoulder too. '
79
+ + `Reply with JSON only: {${names.map((v) => `"${v}": {"ok": true or false, "why": "a few words"}`).join(', ')}}`,
80
+ });
81
+ return Object.fromEntries(names.flatMap((v) => (a[v]?.ok === false ? [[v, String(a[v]?.why ?? 'carried thing in the wrong place')]] : [])));
82
+ }
83
+
84
+ /** Rounds of repainting the views a carried thing went wrong in. */
85
+ const CARRIED_ROUNDS = 2;
86
+
87
+ /** Front from the cast painting; back from the front; the profiles from front and back, so the hood or hat stays the same.
88
+ * What they carry (a satchel, a lantern) is read off the front and placed by name in every other view, then all four
89
+ * are checked together and a view that put it on the wrong side, or put something down at the feet, is painted again.
90
+ * A creature (not a person on two legs: a round eye, a frog, a living teapot) is painted as it is, with no A-pose and no hands. */
91
+ // The image safety filter refuses a prompt on single words (a long list of things to leave out reads as a list of
92
+ // weapons): the view is asked once more in calm words, with the looks left to the picture and the hands said plainly.
93
+ const HANDS_CALM = 'Both hands open and empty, well apart from the body, nothing held and nothing else in the picture.';
94
+ type EditReq = Parameters<typeof museEdit>[1];
95
+ const viewEdit = (memo: Memo, looks: string) => (req: EditReq) => museEdit(memo, req).catch((error: unknown) => {
96
+ if (!refusedByFilter(error)) throw error;
97
+ console.log(` ${req.label}: the image safety filter refused the view; asking in calm words`);
98
+ const prompt = req.prompt.split(HANDS).join(HANDS_CALM).split(` (${looks})`).join('');
99
+ return museEdit(memo, { ...req, label: `${req.label}-calm`, prompt });
100
+ });
101
+
102
+ export async function paintViews(memo: Memo, key: string, looks: string, painting: Buffer, take = 0, creature = false, onView?: Landed, again = true): Promise<Record<ViewName, Buffer>> {
103
+ if (creature) return paintCreatureViews(memo, key, looks, painting, take, onView);
104
+ const edit = viewEdit(memo, looks);
105
+ const front = await edit({
106
+ label: `${key}-view-front`, size: SIZE, take, images: [painting],
107
+ prompt: `The same character as in the picture (${looks}), same clothes, colours and painted style, now seen straight from the front, ${STAND}, face looking at the viewer. Anything they carry (a bag, a satchel) stays where it is on their body in the picture. ${HANDS} ${PLAIN}`,
108
+ });
109
+ onView?.('front', front);
110
+ const items = await readCarried(memo, key, front);
111
+ if (items.length) console.log(` ${key}: carries ${items.map((i) => `${i.name} (${i.at})`).join(', ')}`);
112
+ const paintBack = (t: number) => edit({
113
+ label: `${key}-view-back`, size: SIZE, take: t, images: [front],
114
+ prompt: `The same character as in the picture (${looks}), same clothes, colours, patches and painted style, in exactly the same pose, seen straight from behind: we see the back of the head, the back of the clothes and the heels; the face is not visible. Left and right swap when seen from behind. ${carriedLine('back', items)} ${HANDS} ${SAME_HEAD} Same size and position in the frame as the picture. ${PLAIN}`,
115
+ });
116
+ const views: Record<ViewName, Buffer> = { front, back: await paintBack(take), left: front, right: front };
117
+ onView?.('back', views.back);
118
+ const side = (facing: 'left' | 'right', t: number) => edit({
119
+ label: `${key}-view-${facing}`, size: SIZE, take: t, images: [front, views.back],
120
+ prompt: `The same character as in both pictures (front and back views), same clothes, colours, patches and painted style, now seen exactly from the side in profile, facing to the ${facing} of the picture, so we see their ${facing} side (${looks}). ${carriedLine(facing, items)} ${SAME_HEAD} Keep exactly the same rigging A-pose as the front and back: straight arms angled out sideways 35 degrees from vertical, hands clear of the thighs, legs straight and shoulder-width apart. Do not lower the arms against the hips for this profile. ${HANDS} Same size and position in the frame as the pictures. ${PLAIN}`,
121
+ });
122
+ [views.left, views.right] = await Promise.all((['left', 'right'] as const).map((f) => side(f, take).then((png) => (onView?.(f, png), png))));
123
+ for (let round = 0; round < CARRIED_ROUNDS; round++) {
124
+ const wrong = await checkCarried(memo, key, views, items, round);
125
+ // The other three are painted from the front: a wrong front means all four again, once.
126
+ if (wrong.front && again) {
127
+ console.log(` ${key}: front view: ${wrong.front}; painting all four again`);
128
+ return paintViews(memo, key, looks, painting, take + 1, false, onView, false);
129
+ }
130
+ delete wrong.front;
131
+ const redo = Object.keys(wrong) as ViewName[];
132
+ if (!redo.length) break;
133
+ console.log(` ${key}: ${redo.map((v) => `${v} view: ${wrong[v]}`).join('; ')}; painting ${redo.length === 1 ? 'it' : 'them'} again`);
134
+ const t = take + 1 + round;
135
+ if (wrong.back) (views.back = await paintBack(t), onView?.('back', views.back));
136
+ await Promise.all((['left', 'right'] as const).filter((f) => wrong[f]).map(async (f) => { views[f] = await side(f, t); onView?.(f, views[f]); }));
137
+ }
138
+ return views;
139
+ }
140
+
141
+ // A creature keeps its own shape: whatever feet, fins or nothing it has, as in the painting. The whole body
142
+ // in every view, the same size and position, so TRELLIS builds one body from all four.
143
+ const REST = 'resting in its natural pose, exactly the body shape, proportions, feet, eyes and markings of the picture, nothing added (no arms, no legs, no clothes it does not have)';
144
+
145
+ async function paintCreatureViews(memo: Memo, key: string, looks: string, painting: Buffer, take: number, onView?: Landed): Promise<Record<ViewName, Buffer>> {
146
+ const edit = viewEdit(memo, looks);
147
+ const front = await edit({
148
+ label: `${key}-view-front`, size: SIZE, take, images: [painting],
149
+ prompt: `The same creature as in the picture (${looks}), same colours and painted style, now seen straight from the front, ${REST}, its face (or its front) to the viewer. The whole creature, centred, filling about two thirds of the frame. ${PLAIN}`,
150
+ });
151
+ onView?.('front', front);
152
+ const back = await edit({
153
+ label: `${key}-view-back`, size: SIZE, take, images: [front],
154
+ prompt: `The same creature as in the picture (${looks}), same colours and painted style, in exactly the same pose, seen straight from behind: we see its back, its face is not visible. Same size and position in the frame as the picture. ${PLAIN}`,
155
+ });
156
+ onView?.('back', back);
157
+ const side = (facing: 'left' | 'right') => edit({
158
+ label: `${key}-view-${facing}`, size: SIZE, take, images: [front, back],
159
+ prompt: `The same creature as in both pictures (front and back views), same colours and painted style, now seen exactly from the side in profile, facing to the ${facing} of the picture (${looks}), ${REST}. Same size and position in the frame as the pictures. ${PLAIN}`,
160
+ }).then((png) => (onView?.(facing, png), png));
161
+ const [left, right] = await Promise.all([side('left'), side('right')]);
162
+ return { front, back, left, right };
163
+ }