@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,103 @@
1
+ // Muse Voice Transcribe realtime transport. File uploads use voice-file.ts through speech/transcribe.ts.
2
+ import { apiKey } from './http.ts';
3
+ import { inputHash, MUSE_VOICE, type Memo } from './memo.ts';
4
+
5
+ export const VOICE_TRANSCRIBE = 'muse-voice-transcribe-1.0';
6
+ const URL_ASR = 'wss://api.meta.ai/v1/asr/realtime';
7
+ /** 80 ms of 16-bit mono PCM at 24 kHz. */
8
+ const FRAME = 3840;
9
+
10
+ // One active ASR stream per process, including the retry cooldown. Voice Design and speech share it.
11
+ let queue: Promise<unknown> = Promise.resolve();
12
+ // "Ingress audio slower than real-time": the server drops a stream that falls behind the clock (a busy event loop
13
+ // while a build paints and masks). It is the pace, not the audio: the same take goes again, on the clock.
14
+ const transient = (error: unknown) => /backlog|busy|overload|socket error|timed out|closed 10(06|11|13)|slower than real-?time|real-?time/i.test(String(error));
15
+
16
+ /** `pcm24k`: 16-bit mono PCM at 24 kHz. Never send faster than real time, even for legacy callers. */
17
+ export function transcribe(memo: Memo, label: string, pcm24k: Buffer, pace = 1): Promise<string> {
18
+ return memo.remember('voice', label, inputHash(VOICE_TRANSCRIBE, pcm24k), 'txt', () => {
19
+ const run = queue.then(async () => {
20
+ // Real time exactly: the server refuses audio slower than real time (halving the pace on a retry turned every
21
+ // backlog into a sure "slower than real-time"), and faster than real time is never sent either.
22
+ const rate = Number.isFinite(pace) ? Math.max(0.25, Math.min(1, pace)) : 1;
23
+ for (let attempt = 0; ; attempt++) {
24
+ try { return await listen(label, pcm24k, rate); }
25
+ catch (error) {
26
+ if (!transient(error) || attempt === 3) throw error;
27
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
28
+ }
29
+ }
30
+ });
31
+ queue = run.catch(() => {});
32
+ return run.then((data) => ({ data, via: MUSE_VOICE }));
33
+ }).then((b) => b.toString('utf8'));
34
+ }
35
+
36
+ async function listen(label: string, pcm24k: Buffer, pace: number): Promise<Buffer> {
37
+ const ws = new WebSocket(URL_ASR);
38
+ ws.binaryType = 'arraybuffer';
39
+ const finals: string[] = [];
40
+ let partial = '';
41
+ // Budget includes transmission at the current rate, then 30 seconds for the final transcript.
42
+ const audio = Buffer.concat([pcm24k, Buffer.alloc(FRAME * 12)]);
43
+ const done = new Promise<string>((resolve, reject) => {
44
+ let stopped = false, opened = false, ended = false;
45
+ let sendTimer: ReturnType<typeof setTimeout> | undefined;
46
+ let wake: (() => void) | undefined;
47
+ const wait = (ms: number) => new Promise<void>((r) => { wake = r; sendTimer = setTimeout(() => { wake = undefined; r(); }, ms); });
48
+ const finish = (error?: Error) => {
49
+ if (stopped) return;
50
+ stopped = true;
51
+ clearTimeout(timer); clearTimeout(sendTimer); wake?.();
52
+ if (error) reject(error);
53
+ else resolve([...finals, partial].join(' ').replace(/\s+/g, ' ').trim());
54
+ if (ws.readyState < 2) ws.close(1000);
55
+ };
56
+ const fail = (reason: string) => finish(new Error(`transcribe ${label}: ${reason}`));
57
+ const timer = setTimeout(() => fail('timed out'), Math.max(60000, audio.length / 48000 / pace * 1000 + 30000));
58
+ const sendAudio = async () => {
59
+ // Each frame goes when the clock says, counted from the first: waiting a frame's length after each send added
60
+ // the timer's lateness every 80 ms, and a busy moment never caught up, so the stream drifted behind real time.
61
+ const t0 = Date.now();
62
+ for (let at = 0; at < audio.length; at += FRAME) {
63
+ while (!stopped && ws.bufferedAmount > FRAME * 4) await wait(20);
64
+ if (stopped || ws.readyState !== 1) return;
65
+ const frame = audio.subarray(at, at + FRAME);
66
+ ws.send(frame);
67
+ if (stopped) return;
68
+ const due = t0 + ((at + frame.length) / 48000 / pace) * 1000;
69
+ const ahead = due - Date.now();
70
+ if (ahead > 0) await wait(ahead);
71
+ }
72
+ if (!stopped && ws.readyState === 1) {
73
+ ended = true;
74
+ ws.send(JSON.stringify({ type: 'endStream' }));
75
+ }
76
+ };
77
+ ws.onerror = () => fail('socket error');
78
+ ws.onclose = (e) => {
79
+ if (e.code === 1000 && ended) finish();
80
+ else fail(`closed ${e.code} ${e.reason}${e.code === 1000 ? ' before endStream' : ''}`);
81
+ };
82
+ ws.onmessage = (e) => {
83
+ if (stopped) return;
84
+ try {
85
+ const msg = JSON.parse(String(e.data)) as { type?: string; sessionId?: string; transcript?: string; final?: boolean; message?: string };
86
+ if (!msg.type && msg.sessionId && !opened) {
87
+ opened = true;
88
+ void sendAudio().catch((error) => fail(`socket error: ${error}`));
89
+ } else if (msg.type === 'transcript' && msg.transcript !== undefined) {
90
+ if (msg.final) { finals.push(msg.transcript); partial = ''; }
91
+ else partial = msg.transcript;
92
+ } else if (msg.type === 'error') fail(String(msg.message).slice(0, 300));
93
+ } catch { fail('invalid server message'); }
94
+ };
95
+ ws.onopen = () => {
96
+ if (stopped) return;
97
+ try {
98
+ ws.send(JSON.stringify({ authorization: { accessToken: `Bearer ${apiKey()}` }, audioEncoding: 'PCM_24KHZ', model: VOICE_TRANSCRIBE, mode: 'PUSH_TO_TALK' }));
99
+ } catch (error) { fail(String(error)); }
100
+ };
101
+ });
102
+ return Buffer.from(await done, 'utf8');
103
+ }
@@ -0,0 +1,38 @@
1
+ // Per-character choices live outside generated model/ and sprites/ so regeneration keeps them.
2
+ import fs from 'node:fs';
3
+ import { z } from 'zod';
4
+ import { hasModelTexture } from './glb.ts';
5
+ import type { WorldFiles } from '../world/files.ts';
6
+
7
+ const Texture = z.enum(['views', 'model']);
8
+ const Frames = z.union([z.literal(8), z.literal(16), z.literal(32)]);
9
+ const Settings = z.object({ texture: Texture, frames: Frames, coatCorrection: z.boolean().optional(), automatic: z.literal(true).optional() });
10
+ export type AnimationSettings = z.infer<typeof Settings>;
11
+
12
+ /** Existing choices win; new characters select UV/16 only when their actual model supports it. */
13
+ export function animationSettings(files: WorldFiles, key: string, patch: Partial<AnimationSettings> = {}, candidate?: Buffer): AnimationSettings {
14
+ const file = files.path('cast', key, 'animation.json');
15
+ const saved = fs.existsSync(file) ? Settings.parse(files.readJson(file)) : undefined;
16
+ const modelFile = files.path('cast', key, 'model', 'model.json');
17
+ const model = fs.existsSync(modelFile) ? files.readJson(modelFile) as { texture?: string } : undefined;
18
+ const legacy = !saved && model?.texture !== 'model' && fs.existsSync(files.path('cast', key, 'sprites', 'sprites.json'));
19
+ const automatic = patch.texture === undefined && patch.frames === undefined && (!saved || saved.automatic) && !legacy;
20
+ const glbFile = files.path('cast', key, 'model', 'model.glb');
21
+ const glb = candidate ?? (fs.existsSync(glbFile) ? fs.readFileSync(glbFile) : undefined);
22
+ const texture = automatic ? glb && hasModelTexture(glb) ? 'model' : 'views' : patch.texture ?? saved?.texture ?? 'views';
23
+ const changedMode = patch.texture !== undefined && patch.texture !== saved?.texture;
24
+ const frames = automatic ? texture === 'model' ? 16 : 8 : patch.frames ?? (changedMode ? texture === 'model' ? 16 : 8 : saved?.frames ?? (texture === 'model' ? 16 : 8));
25
+ // Automatic UV starts with the rig's own weights. A historical coat override remains respected.
26
+ const coatCorrection = patch.coatCorrection ?? saved?.coatCorrection ?? (automatic && texture === 'model' ? false : undefined);
27
+ const settings = Settings.parse({ texture, frames, ...(coatCorrection !== undefined && { coatCorrection }), ...(automatic && { automatic: true }) });
28
+ if (texture === 'views' && frames !== 8) throw new Error('Painted views use 8 frames; use --texture model for 16 or 32 frames');
29
+ return settings;
30
+ }
31
+
32
+ export function animationOptions(texture: string, frames = '', coat = ''): Partial<AnimationSettings> {
33
+ return { ...(texture && { texture: Texture.parse(texture) }), ...(frames && { frames: Frames.parse(Number(frames)) }), ...(coat && { coatCorrection: z.enum(['on', 'off']).parse(coat) === 'on' }) };
34
+ }
35
+
36
+ export function saveAnimationSettings(files: WorldFiles, key: string, settings: AnimationSettings) {
37
+ files.write(files.path('cast', key, 'animation.json'), settings);
38
+ }
@@ -0,0 +1,284 @@
1
+ # Blender (headless) step for a rigged figure: puts it into a calm standing rest pose before it walks.
2
+ #
3
+ # blender -b --factory-startup --python relax.py -- in.glb out.glb [report.json]
4
+ #
5
+ # TRELLIS rebuilds the figure in whatever pose Muse painted: arms often held up and out (a Y-pose), feet
6
+ # wide apart. The auto-rig skins it in that pose, and the game then swings arms ~100 degrees down and walks
7
+ # legs that are planted wide, so every frame shows the skinning's worst case: shoulders pinched, sleeves
8
+ # glued to the chest, hands sticking out, a waddle. Here Blender poses arms down and feet under the hips
9
+ # once, smooths the deformed shoulders and hips with a Corrective Smooth, and makes that the new rest pose.
10
+ # The game's own walk then turns bones only a little from rest. The original positions travel along as the
11
+ # _REST vertex attribute, so paintings projected in the original pose still land on the same surface.
12
+ import bpy, sys, json, math
13
+ from mathutils import Vector, Matrix
14
+
15
+ argv = sys.argv[sys.argv.index('--') + 1:]
16
+ src, dst = argv[0], argv[1]
17
+ report_path = argv[2] if len(argv) > 2 else None
18
+ report = {'ok': False}
19
+
20
+
21
+ def bind_pose_glb(path, out):
22
+ """The glb with every joint node moved to its bind pose (mesh world x inverse bind matrix).
23
+
24
+ Make-It-Animatable saves its bones in its own T-pose (seated, leant back for a long coat) while the mesh
25
+ is in the pose TRELLIS built; three.js and the game bind with the inverse bind matrices, Blender takes
26
+ the nodes as rest. Moving the nodes makes both agree (the same as web/hero3d.ts toBindPose)."""
27
+ import struct, numpy as np
28
+ data = open(path, 'rb').read()
29
+ n = struct.unpack_from('<I', data, 12)[0]
30
+ gj = json.loads(data[20:20 + n])
31
+ rest = data[20 + n:]
32
+ blen = struct.unpack_from('<I', rest, 0)[0]
33
+ blob = rest[8:8 + blen]
34
+ nodes = gj['nodes']
35
+
36
+ def local(nd):
37
+ if 'matrix' in nd:
38
+ return np.array(nd['matrix'], dtype=float).reshape(4, 4).T
39
+ T = np.eye(4); T[:3, 3] = nd.get('translation', [0, 0, 0])
40
+ x, y, z, w = nd.get('rotation', [0, 0, 0, 1])
41
+ R = np.eye(4)
42
+ R[:3, :3] = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
43
+ [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
44
+ [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
45
+ S = np.diag(list(nd.get('scale', [1, 1, 1])) + [1])
46
+ return T @ R @ S
47
+ parent = {c: i for i, nd in enumerate(nodes) for c in nd.get('children', [])}
48
+
49
+ def world(i):
50
+ M = local(nodes[i])
51
+ while i in parent:
52
+ i = parent[i]
53
+ M = local(nodes[i]) @ M
54
+ return M
55
+ mesh_node = next(i for i, nd in enumerate(nodes) if 'skin' in nd)
56
+ skin = gj['skins'][nodes[mesh_node]['skin']]
57
+ acc = gj['accessors'][skin['inverseBindMatrices']]
58
+ bv = gj['bufferViews'][acc['bufferView']]
59
+ off = bv.get('byteOffset', 0) + acc.get('byteOffset', 0)
60
+ ibm = np.frombuffer(blob[off:off + 64 * acc['count']], dtype='<f4').reshape(-1, 4, 4).transpose(0, 2, 1)
61
+ MW = world(mesh_node)
62
+ joints = skin['joints']
63
+ depth = lambda i: 0 if i not in parent else 1 + depth(parent[i])
64
+ for k in sorted(range(len(joints)), key=lambda k: depth(joints[k])):
65
+ j = joints[k]
66
+ bind = MW @ np.linalg.inv(ibm[k].astype(float))
67
+ pw = world(parent[j]) if j in parent else np.eye(4)
68
+ L = np.linalg.inv(pw) @ bind
69
+ for key in ('translation', 'rotation', 'scale'):
70
+ nodes[j].pop(key, None)
71
+ nodes[j]['matrix'] = [float(v) for v in L.T.reshape(-1)]
72
+ js = json.dumps(gj).encode()
73
+ js += b' ' * (-len(js) % 4)
74
+ body = struct.pack('<II', len(js), 0x4E4F534A) + js + rest
75
+ open(out, 'wb').write(struct.pack('<III', 0x46546C67, 2, 12 + len(body)) + body)
76
+
77
+
78
+ import os, tempfile
79
+ bound = os.path.join(tempfile.mkdtemp(), 'bind.glb')
80
+ bind_pose_glb(src, bound)
81
+ bpy.ops.wm.read_factory_settings(use_empty=True)
82
+ # Seams merged: Corrective Smooth would otherwise smooth each UV island apart and open cracks.
83
+ bpy.ops.import_scene.gltf(filepath=bound, merge_vertices=True)
84
+ arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
85
+ meshes = [o for o in bpy.data.objects if o.type == 'MESH' and any(m.type == 'ARMATURE' for m in o.modifiers)]
86
+ if not meshes:
87
+ raise SystemExit('no skinned mesh')
88
+ mesh = max(meshes, key=lambda o: len(o.data.vertices))
89
+ if mesh.data.shape_keys:
90
+ raise SystemExit('the mesh has shape keys (facial morphs): its pose is left as it is')
91
+ # Some rigs turn the mesh node itself (Make-It-Animatable leans it ~30 degrees); glTF ignores a skinned
92
+ # mesh's own node, and three.js binds with it, so it is baked into the vertices first.
93
+ # The same for the nodes above the armature: every object ends at identity, so the exported inverse bind
94
+ # matrices and the mesh agree whatever the reader does with node transforms.
95
+ def flatten(o):
96
+ for x in bpy.context.selected_objects:
97
+ x.select_set(False)
98
+ o.select_set(True)
99
+ bpy.context.view_layer.objects.active = o
100
+ bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
101
+ bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
102
+ flatten(mesh)
103
+ flatten(arm)
104
+ for o in list(bpy.data.objects):
105
+ if o.type == 'EMPTY':
106
+ bpy.data.objects.remove(o)
107
+ mesh.parent = arm
108
+ pb = {b.name.split(':')[-1]: b for b in arm.pose.bones}
109
+ need = ['Hips', 'LeftArm', 'LeftForeArm', 'LeftHand', 'RightArm', 'RightForeArm', 'RightHand', 'LeftUpLeg', 'LeftFoot', 'RightUpLeg', 'RightFoot']
110
+ missing = [n for n in need if n not in pb]
111
+ if missing:
112
+ raise SystemExit('bones missing: ' + ', '.join(missing))
113
+
114
+ bpy.context.view_layer.objects.active = arm
115
+ for b in arm.pose.bones:
116
+ b.rotation_mode = 'QUATERNION'
117
+ b.matrix_basis = Matrix.Identity(4)
118
+ bpy.context.view_layer.update()
119
+ W = arm.matrix_world
120
+
121
+ def head(n):
122
+ return W @ pb[n].head
123
+
124
+ def rotate_world(n, R):
125
+ """Turn bone n (and so its children) by world rotation R about its head."""
126
+ b = pb[n]
127
+ h = head(n)
128
+ world = W @ b.matrix
129
+ world = Matrix.Translation(h) @ R.to_matrix().to_4x4() @ Matrix.Translation(-h) @ world
130
+ b.matrix = W.inverted() @ world
131
+ bpy.context.view_layer.update()
132
+
133
+ # Which way is up, and which way the figure faces, in Blender's world (glTF +y up, +z facing -> Blender +z, -y).
134
+ up = Vector((0, 0, 1))
135
+ height = max((mesh.matrix_world @ v.co).z for v in mesh.data.vertices) - min((mesh.matrix_world @ v.co).z for v in mesh.data.vertices)
136
+ report['height'] = height
137
+
138
+ # Arms: shoulder -> wrist straight down and a little out, in the figure's own frontal plane.
139
+ ARM_OUT = math.radians(5)
140
+ arms = {}
141
+ for side, s in (('Left', 1), ('Right', -1)):
142
+ a, w = head(f'{side}Arm'), head(f'{side}Hand')
143
+ cur = (w - a).normalized()
144
+ out = Vector((s, 0, 0))
145
+ want = (out * math.sin(ARM_OUT) - up * math.cos(ARM_OUT)).normalized()
146
+ arms[side] = math.degrees(cur.angle(want))
147
+ rotate_world(f'{side}Arm', cur.rotation_difference(want))
148
+ # Elbow: forearm in line with the upper arm, a slight natural bend forward.
149
+ e, w = head(f'{side}ForeArm'), head(f'{side}Hand')
150
+ cur = (w - e).normalized()
151
+ upper = (e - head(f'{side}Arm')).normalized()
152
+ bend = Matrix.Rotation(math.radians(8), 3, Vector((1, 0, 0)))
153
+ want = (bend @ upper).normalized()
154
+ if want.y > upper.y: # bend toward the front (-y), never backwards
155
+ want = (bend.inverted() @ upper).normalized()
156
+ rotate_world(f'{side}ForeArm', cur.rotation_difference(want))
157
+ # Hand in line with the forearm (a held-up palm otherwise sticks out sideways).
158
+ hb = pb[f'{side}Hand']
159
+ tail = W @ hb.tail
160
+ cur = (tail - head(f'{side}Hand')).normalized()
161
+ fore = (head(f'{side}Hand') - head(f'{side}ForeArm')).normalized()
162
+ rotate_world(f'{side}Hand', cur.rotation_difference(fore))
163
+ report['armTurn'] = arms
164
+
165
+ # Palms toward the thighs. A raised hand is painted palm forward or down; turned down with the arm it faces
166
+ # back or out, and the fingers splay away from the body like a zombie's. The palm's normal is the hand's
167
+ # flattest direction (least spread of its vertices, posed); the hand turns about the forearm until it points
168
+ # across the body.
169
+ def posed_points(group_name):
170
+ g = mesh.vertex_groups.get(group_name)
171
+ if g is None:
172
+ return []
173
+ dg = bpy.context.evaluated_depsgraph_get()
174
+ ev = mesh.evaluated_get(dg).data
175
+ pts = []
176
+ for v in mesh.data.vertices:
177
+ for vg in v.groups:
178
+ if vg.group == g.index and vg.weight > 0.6:
179
+ pts.append(mesh.matrix_world @ ev.vertices[v.index].co)
180
+ return pts
181
+
182
+ palms = {}
183
+ for side, s in (('Left', 1), ('Right', -1)):
184
+ name = pb[f'{side}Hand'].name
185
+ pts = posed_points(name)
186
+ if len(pts) < 12:
187
+ continue
188
+ import numpy as np
189
+ P = np.array([tuple(p) for p in pts])
190
+ P -= P.mean(axis=0)
191
+ _, _, vt = np.linalg.svd(P, full_matrices=False)
192
+ normal = Vector(vt[2].tolist())
193
+ axis = (head(f'{side}Hand') - head(f'{side}ForeArm')).normalized()
194
+ n = (normal - axis * normal.dot(axis))
195
+ want = Vector((1, 0, 0)) - axis * axis.x
196
+ if n.length < 1e-6 or want.length < 1e-6:
197
+ continue
198
+ n.normalize(); want.normalize()
199
+ if n.dot(want) < 0:
200
+ n = -n # a plane's normal has no sign: the smaller turn
201
+ angle = math.atan2(n.cross(want).dot(axis), n.dot(want))
202
+ palms[side] = math.degrees(angle)
203
+ if abs(angle) > math.radians(10):
204
+ rotate_world(f'{side}Hand', Matrix.Rotation(angle, 3, axis).to_quaternion())
205
+ report['palmTurn'] = palms
206
+
207
+ # Legs: ankle under the hip joint, turned only in the frontal plane; feet keep their world orientation (flat).
208
+ legs = {}
209
+ for side, s in (('Left', 1), ('Right', -1)):
210
+ foot_world = (W @ pb[f'{side}Foot'].matrix).to_quaternion()
211
+ h, f = head(f'{side}UpLeg'), head(f'{side}Foot')
212
+ cur = f - h
213
+ # Only the sideways part changes (ankle a quarter of the hip joint's offset out): same forward lean.
214
+ want = Vector(((h.x - head('Hips').x) * 0.25, cur.y, cur.z))
215
+ legs[side] = math.degrees(cur.angle(want))
216
+ if legs[side] < 25: # more is a kick or a stride, not a stance
217
+ rotate_world(f'{side}UpLeg', cur.normalized().rotation_difference(want.normalized()))
218
+ now = (W @ pb[f'{side}Foot'].matrix).to_quaternion()
219
+ rotate_world(f'{side}Foot', foot_world @ now.inverted())
220
+ report['legTurn'] = legs
221
+
222
+ # Bake the pose into the mesh, smoothing what skinning pinched, then make it the rest pose.
223
+ bpy.ops.object.mode_set(mode='OBJECT')
224
+ orig = [v.co.copy() for v in mesh.data.vertices]
225
+ armmod = next(m for m in mesh.modifiers if m.type == 'ARMATURE')
226
+ # Corrective Smooth only where the pose moved the surface: elsewhere (the head, its hair strands) it still
227
+ # shifts thin pieces a little, which drags hair across the face once the paintings are projected on it.
228
+ bpy.context.view_layer.objects.active = mesh
229
+ dg = bpy.context.evaluated_depsgraph_get()
230
+ posed = [v.co.copy() for v in mesh.evaluated_get(dg).data.vertices]
231
+ moved = mesh.vertex_groups.new(name='relax_moved')
232
+ for i, (a, b) in enumerate(zip(posed, orig)):
233
+ w = min(1.0, (a - b).length / (0.03 * height))
234
+ if w > 0.02:
235
+ moved.add([i], w, 'REPLACE')
236
+ cs = mesh.modifiers.new('relax', 'CORRECTIVE_SMOOTH')
237
+ cs.vertex_group = moved.name
238
+ cs.rest_source = 'ORCO'
239
+ cs.smooth_type = 'LENGTH_WEIGHTED'
240
+ cs.iterations = 8
241
+ cs.factor = 0.5
242
+ cs.use_only_smooth = False
243
+ bpy.context.view_layer.objects.active = mesh
244
+ dg = bpy.context.evaluated_depsgraph_get()
245
+ ev = mesh.evaluated_get(dg)
246
+ baked = [v.co.copy() for v in ev.data.vertices]
247
+ if len(baked) != len(orig):
248
+ raise SystemExit('modifier changed vertex count')
249
+ mesh.modifiers.remove(cs)
250
+ mesh.vertex_groups.remove(moved)
251
+ for v, p in zip(mesh.data.vertices, baked):
252
+ v.co = p
253
+ bpy.context.view_layer.objects.active = arm
254
+ bpy.ops.object.mode_set(mode='POSE')
255
+ bpy.ops.pose.armature_apply(selected=False)
256
+ bpy.ops.object.mode_set(mode='OBJECT')
257
+
258
+ # The original surface, in glTF's axes (x, z, -y) and the mesh's own space, for projecting the paintings.
259
+ attr = mesh.data.attributes.new('_REST', 'FLOAT_VECTOR', 'POINT')
260
+ for i, p in enumerate(orig):
261
+ attr.data[i].vector = (p.x, p.z, -p.y)
262
+
263
+ # Stray pieces (the 42-vertex marker sphere some rigs carry) stay out of the export.
264
+ for o in list(bpy.data.objects):
265
+ if o.type == 'MESH' and o is not mesh:
266
+ bpy.data.objects.remove(o)
267
+
268
+ # Smooth shading, or the exporter splits every triangle corner into its own vertex (6.9k -> 27.7k): the game
269
+ # then works out faceted normals and per-corner visibility, and the painted face breaks up into blotches.
270
+ for poly in mesh.data.polygons:
271
+ poly.use_smooth = True
272
+ move = max((a - b).length for a, b in zip(baked, orig)) / height
273
+ report['maxMove'] = move
274
+ # The head must not move at all: a shifted strand of hair or cheek shows as a torn face in every sprite.
275
+ head_groups = {g.index for g in mesh.vertex_groups if g.name.split(':')[-1] in ('Head', 'Neck', 'HeadTop_End')}
276
+ on_head = [any(vg.group in head_groups and vg.weight > 0.5 for vg in v.groups) for v in mesh.data.vertices]
277
+ report['headMove'] = max(((a - b).length for a, b, h in zip(baked, orig, on_head) if h), default=0) / height
278
+ bpy.ops.export_scene.gltf(filepath=dst, export_format='GLB', export_attributes=True, export_skins=True,
279
+ export_animations=False, export_yup=True, export_apply=False)
280
+ report['ok'] = True
281
+ if report_path:
282
+ with open(report_path, 'w') as f:
283
+ json.dump(report, f)
284
+ print('RELAX', json.dumps(report))
@@ -0,0 +1,115 @@
1
+ // Optional local Blender. When it is installed, a rigged figure is put into a calm rest pose (arms down, feet
2
+ // under the hips) before the game walks it: src/model/blender/relax.py. Without Blender nothing changes; the
3
+ // game turns the bones from the pose TRELLIS built, as before.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { configDir } from '../secrets.ts';
11
+
12
+ const exec = promisify(execFile);
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
14
+ export const RELAX_SCRIPT = path.join(HERE, 'blender', 'relax.py');
15
+
16
+ /** Machine-wide choice from Setup: use Blender when it is found (default), or not. */
17
+ export type BlenderSettings = { enabled: boolean };
18
+ const settingsFile = () => path.join(configDir(), 'blender.json');
19
+
20
+ export function readBlenderSettings(): BlenderSettings {
21
+ try {
22
+ return { enabled: (JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) as Partial<BlenderSettings>).enabled !== false };
23
+ } catch {
24
+ return { enabled: true };
25
+ }
26
+ }
27
+
28
+ export function writeBlenderSettings(settings: BlenderSettings) {
29
+ fs.mkdirSync(path.dirname(settingsFile()), { recursive: true, mode: 0o700 });
30
+ fs.writeFileSync(settingsFile(), `${JSON.stringify({ enabled: settings.enabled }, null, 2)}\n`, { mode: 0o600 });
31
+ found = undefined;
32
+ }
33
+
34
+ /** DIORAMA_BLENDER as set: a path, 'off', or undefined. */
35
+ export const blenderEnv = () => process.env.DIORAMA_BLENDER;
36
+ const envOff = () => /^(0|off|no|false)$/i.test(blenderEnv() ?? '');
37
+
38
+ /** Where Blender is looked for, in order: DIORAMA_BLENDER (off/0 disables it), PATH, the usual install places. */
39
+ function candidates(): string[] {
40
+ const set = blenderEnv();
41
+ if (set !== undefined) return envOff() ? [] : [set];
42
+ const onPath = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, process.platform === 'win32' ? 'blender.exe' : 'blender'));
43
+ return [
44
+ ...onPath,
45
+ '/Applications/Blender.app/Contents/MacOS/Blender',
46
+ path.join(os.homedir(), 'Applications/Blender.app/Contents/MacOS/Blender'),
47
+ '/snap/bin/blender',
48
+ '/usr/bin/blender',
49
+ '/usr/local/bin/blender',
50
+ 'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe',
51
+ ];
52
+ }
53
+
54
+ let found: Promise<{ bin: string; version: string } | null> | undefined;
55
+
56
+ /** The Blender installed here and its version, whether or not Setup turned it off. Blender 3.6+ (glTF attributes, Corrective Smooth). */
57
+ export function detectBlender(): Promise<{ bin: string; version: string } | null> {
58
+ found ??= (async () => {
59
+ for (const bin of candidates()) {
60
+ if (!fs.existsSync(bin)) continue;
61
+ try {
62
+ const { stdout } = await exec(bin, ['-b', '--factory-startup', '--version'], { timeout: 60_000 });
63
+ const version = /Blender\s+(\d+\.\d+(?:\.\d+)?)/.exec(stdout)?.[1];
64
+ if (!version) continue;
65
+ const [major, minor] = version.split('.').map(Number);
66
+ if (major < 3 || (major === 3 && minor < 6)) continue;
67
+ return { bin, version };
68
+ } catch {
69
+ continue;
70
+ }
71
+ }
72
+ return null;
73
+ })();
74
+ return found;
75
+ }
76
+
77
+ /** The Blender to use, or null: none installed, or turned off in Setup (or DIORAMA_BLENDER=off). */
78
+ export async function findBlender(): Promise<{ bin: string; version: string } | null> {
79
+ if (envOff() || (blenderEnv() === undefined && !readBlenderSettings().enabled)) return null;
80
+ return detectBlender();
81
+ }
82
+
83
+ export async function blenderState() {
84
+ const settings = readBlenderSettings();
85
+ const installed = await detectBlender();
86
+ return { enabled: blenderEnv() !== undefined ? !envOff() : settings.enabled, installed, env: blenderEnv() ?? null,
87
+ active: !!(await findBlender()) };
88
+ }
89
+
90
+ export type RelaxReport = { ok: boolean; height?: number; armTurn?: Record<string, number>; legTurn?: Record<string, number>;
91
+ palmTurn?: Record<string, number>; maxMove?: number; headMove?: number };
92
+
93
+ /**
94
+ * The rigged glb in a calm rest pose, or null when there is no Blender or it failed (the caller keeps the
95
+ * original). The original surface rides along as the _REST attribute, for the painted views' projection.
96
+ */
97
+ export async function relaxPose(glb: Buffer, log: (line: string) => void = () => {}): Promise<{ glb: Buffer; report: RelaxReport } | null> {
98
+ const blender = await findBlender();
99
+ if (!blender) return null;
100
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'diorama-relax-'));
101
+ try {
102
+ const [src, dst, rep] = ['in.glb', 'out.glb', 'report.json'].map((f) => path.join(dir, f));
103
+ fs.writeFileSync(src, glb);
104
+ await exec(blender.bin, ['-b', '--factory-startup', '--python-exit-code', '1', '--python', RELAX_SCRIPT, '--', src, dst, rep], { timeout: 300_000, maxBuffer: 1 << 26 });
105
+ const report = JSON.parse(fs.readFileSync(rep, 'utf8')) as RelaxReport;
106
+ if (!report.ok || !fs.existsSync(dst)) return null;
107
+ return { glb: fs.readFileSync(dst), report };
108
+ } catch (error) {
109
+ const e = error as { stderr?: string; message?: string };
110
+ log(`Blender could not relax the pose: ${(e.stderr || e.message || String(error)).trim().split('\n').slice(-3).join(' ')}`);
111
+ return null;
112
+ } finally {
113
+ fs.rmSync(dir, { recursive: true, force: true });
114
+ }
115
+ }
@@ -0,0 +1,50 @@
1
+ // The hero as a 3D model: TRELLIS reconstructs a textured mesh from Muse's four views, then
2
+ // Make-It-Animatable gives it a humanoid skeleton (Mixamo bone names) and skin weights. Both run as
3
+ // public Hugging Face Spaces (MIT); their answers are kept in the world's memo like model calls.
4
+ import { inputHash, type Memo } from '../meta/memo.ts';
5
+ import { filesNamed, Space } from './space.ts';
6
+ import { inLane } from '../world/pool.ts';
7
+
8
+ /** Requests open at once on each Space: its GPU queue serves one at a time anyway, a second keeps it busy. */
9
+ const SPACE_LANES = Math.max(1, Number(process.env.DIORAMA_SPACE_LANES) || 2);
10
+
11
+ export const TRELLIS = 'trellis-community/TRELLIS';
12
+ export const AUTORIG = 'jasongzy/Make-It-Animatable';
13
+
14
+ /** Muse's views (PNG, front first) to a textured GLB, about 30 s of GPU. With the profiles too, TRELLIS
15
+ * builds the body as deep as it is painted instead of guessing the depth from the front. */
16
+ export async function reconstruct(memo: Memo, key: string, views: Buffer[], seed = 1): Promise<Buffer> {
17
+ return memo.remember('space', `${key}-trellis`, inputHash(TRELLIS, String(seed), ...views), 'glb', () => inLane('trellis', SPACE_LANES, async () => {
18
+ const space = new Space(TRELLIS);
19
+ // The app keeps each session's files in its own folder, made when the page loads.
20
+ await space.call('start_session', []);
21
+ const uploads = await Promise.all(views.map((png, i) => space.upload(`view-${i}.png`, png)));
22
+ const [pre] = await space.call('preprocess_images', [uploads.map((image) => ({ image, caption: null }))]);
23
+ const cuts = await Promise.all((pre[0] as { image: { path: string; url?: string } }[]).map(async ({ image }, i) =>
24
+ space.upload(`cut-${i}.png`, await space.download(image))));
25
+ // Selecting the Multiple Images tab is what sets the session's multi-image flag.
26
+ if (cuts.length > 1) await space.call('lambda_1', []);
27
+ // image, multiimages, seed, ss guidance + steps, slat guidance + steps, multiimage algo, mesh simplify, texture size
28
+ const events = await space.call('generate_and_extract_glb', [cuts[0], cuts.map((image) => ({ image, caption: null })), seed, 7.5, 12, 3.0, 12, 'stochastic', 0.95, 1024]);
29
+ const [glb] = filesNamed(events, '.glb');
30
+ if (!glb) throw new Error('TRELLIS answered without a model');
31
+ return space.download(glb);
32
+ }));
33
+ }
34
+
35
+ /** Textured GLB to a skinned GLB in its own pose (no reset to a T-pose, no animation), about 15 s of GPU. */
36
+ export async function autoRig(memo: Memo, key: string, model: Buffer): Promise<Buffer> {
37
+ return memo.remember('space', `${key}-rig`, inputHash(AUTORIG, 'keep-pose', model), 'glb', () => inLane('autorig', SPACE_LANES, async () => {
38
+ const space = new Space(AUTORIG);
39
+ const input = await space.upload(`${key}.glb`, model);
40
+ // input, no fingers, rest pose, rest parts, gaussian, opacity, input normals, weight fix, bone shown,
41
+ // reset to rest, animation file, retarget, in place
42
+ await space.call('pipeline', [input, true, 'No', [], false, 0.01, false, true, 'LeftArm', false, null, true, true]);
43
+ // The pipeline keeps joints and weights in the session; the Blender step after it writes the rigged model.
44
+ // reset to rest, no fingers, rest pose, rest parts, animation file, retarget, in place
45
+ const events = await space.call('vis_blender', [false, true, 'No', [], null, true, true]);
46
+ const rigged = filesNamed(events, `${key}.glb`)[0] ?? filesNamed(events, '.glb').find((f) => !/joints|normed|sample|bw|rest/.test(f.path));
47
+ if (!rigged) throw new Error('auto-rig answered without a rigged model');
48
+ return space.download(rigged);
49
+ }));
50
+ }
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { facialControl, isViseme } from '../play/face3d.ts';
4
+
5
+ export function modelFaceStatus(dir: string, paintedViews: string[] = []): { mouth: boolean; eyes: boolean; targets: number; method: 'morphs' | 'painted-surfaces' | null; views: string[]; reason: string | null } | null {
6
+ const info = path.join(dir, 'model.json');
7
+ if (!fs.existsSync(info)) return null;
8
+ try {
9
+ const model = JSON.parse(fs.readFileSync(info, 'utf8')) as { glb: string; texture?: string };
10
+ const data = fs.readFileSync(path.join(dir, model.glb));
11
+ if (data.readUInt32LE(0) !== 0x46546c67 || data.toString('ascii', 16, 20) !== 'JSON') throw new Error('invalid GLB');
12
+ const gltf = JSON.parse(data.toString('utf8', 20, 20 + data.readUInt32LE(12)).trim()) as { meshes?: Array<{ extras?: { targetNames?: string[] }; primitives?: Array<{ targets?: unknown[] }> }> };
13
+ const controls = (gltf.meshes ?? []).flatMap(m => m.extras?.targetNames ?? []).map(facialControl).filter(c => c !== undefined);
14
+ const mouth = controls.some(c => c === 'jaw' || (isViseme(c) && c !== 'sil'));
15
+ const eyes = controls.some(c => c.startsWith('blink'));
16
+ const targets = (gltf.meshes ?? []).reduce((sum, m) => sum + Math.max(0, ...(m.primitives ?? []).map(p => p.targets?.length ?? 0)), 0);
17
+ const surfaces = (model as {texture?:string}).texture !== 'model' ? paintedViews : [];
18
+ return { mouth: mouth || surfaces.length > 0, eyes: eyes || surfaces.length > 0, targets, method: mouth ? 'morphs' : surfaces.length ? 'painted-surfaces' : null, views: surfaces,
19
+ reason: mouth || surfaces.length ? null : 'The 3D model needs facial morphs or calibrated painted facial surfaces.' };
20
+ } catch { return { mouth: false, eyes: false, targets: 0, method:null, views:[], reason: 'The 3D facial controls could not be inspected.' }; }
21
+ }