@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,65 @@
1
+ // Human/agent-reviewed lip locations are tied to the exact inspected pixels.
2
+ import fs from 'node:fs';
3
+ import sharp from 'sharp';
4
+ import { z } from 'zod';
5
+ import type { Box, FacePose } from '../play/lipsync.ts';
6
+ import { registeredFace, sha256 } from './faces-data.ts';
7
+ import { FaceRevision, LipBox, MouthName } from './face-work.ts';
8
+ import type { WorldFiles } from '../world/files.ts';
9
+
10
+ export const CalibrationTarget = z.discriminatedUnion('kind', [
11
+ z.object({kind:z.literal('source'),view:z.string().min(1),pose:z.number().int().nonnegative()}),
12
+ z.object({kind:z.literal('patch'),view:z.string().min(1),mouth:MouthName}),
13
+ ]);
14
+ type Target = z.infer<typeof CalibrationTarget>;
15
+ const Review = z.object({box:LipBox,open:z.boolean()});
16
+ export function reviewedLips(files:WorldFiles,key:string,image:Buffer) {
17
+ const file=files.path('cast',key,'lip-reviews',`${sha256(image)}.json`);
18
+ return fs.existsSync(file)?Review.parse(files.readJson(file)):undefined;
19
+ }
20
+ export function pixelCrop([x,y,w,h]:Box,width:number,height:number) {
21
+ const left=Math.max(0,Math.floor(x+1e-7)),top=Math.max(0,Math.floor(y+1e-7));
22
+ return {left,top,width:Math.max(1,Math.min(width-left,Math.ceil(x+w-1e-7)-left)),height:Math.max(1,Math.min(height-top,Math.ceil(y+h-1e-7)-top))};
23
+ }
24
+ export async function sourceLipCrop(data:Buffer,cols:number,rows:number,pose:FacePose,index:number) {
25
+ const meta=await sharp(data).metadata(),cw=meta.width!/cols,ch=meta.height!/rows;
26
+ const [mx,my,mw,mh]=pose.mouth;
27
+ const local=pixelCrop([mx-mw*.75,my-mh*1.25,mw*2.5,mh*2.75],Math.floor(cw),Math.floor(ch));
28
+ const rect={...local,left:local.left+index%cols*cw,top:local.top+Math.floor(index/cols)*ch};
29
+ const image=await sharp(data).extract(rect).resize(512,512,{fit:'fill'}).png().toBuffer();
30
+ return {image,rect,cw,ch};
31
+ }
32
+ export async function inspectFaceCalibration(files:WorldFiles,key:string,target?:Target) {
33
+ const face=registeredFace(files,key,'face-candidate.json');
34
+ if(!face)throw new Error('No current face candidate; prepare or inspect saved atlas work first');
35
+ const info=face.info, manifest=files.path('cast',key,'face-candidate.json');
36
+ const revision=sha256(fs.readFileSync(manifest));
37
+ const sources:Target[]=[];
38
+ if(info.imagePose&&!info.imagePose.lips)sources.push({kind:'source',view:'image',pose:0});
39
+ for(const [view,poses] of Object.entries(info.sheets)) if(!view.startsWith('rig:'))poses.forEach((p,i)=>{if(p&&!p.lips)sources.push({kind:'source',view,pose:i});});
40
+ const views=[...new Set([info.imagePose?.atlas,...Object.values(info.sheets).flatMap(p=>p.map(v=>v?.atlas))].filter((v):v is string=>!!v))];
41
+ const patches=views.filter(v=>!info.frames?.[v]).flatMap(view=>MouthName.options.map(mouth=>({kind:'patch' as const,view,mouth})));
42
+ if(!target)return {revision,pending:[...sources,...patches]};
43
+ let image:Buffer;
44
+ if(target.kind==='source') {
45
+ const pose=target.view==='image'?info.imagePose:info.sheets[target.view]?.[target.pose];
46
+ if(!pose || target.view==='image'&&target.pose!==0 || target.view.startsWith('rig:'))throw new Error('No current source pose');
47
+ const model=target.view.startsWith('model:');
48
+ const path=target.view==='image'?files.path('cast',`${key}.png`):files.path('cast',key,model?'model':'sprites',model?target.view.slice(6):target.view);
49
+ const grid=target.view==='image'||model?{cols:1,rows:1}:files.readJson(files.path('cast',key,'sprites','sprites.json')) as {cols:number;rows:number};
50
+ image=(await sourceLipCrop(fs.readFileSync(path),grid.cols,grid.rows,pose,target.pose)).image;
51
+ } else {
52
+ if(!views.includes(target.view))throw new Error('No current patch view');
53
+ const mouths=target.view==='default'?info.mouths:info.atlases?.[target.view];
54
+ if(!mouths)throw new Error('No current mouth patch');
55
+ const atlas=fs.readFileSync(files.path('cast',key,info.image)),meta=await sharp(atlas).metadata();
56
+ image=await sharp(atlas).extract(pixelCrop(mouths[target.mouth],meta.width!,meta.height!)).resize(512,512,{fit:'fill'}).png().toBuffer();
57
+ }
58
+ return {revision,target,imageHash:sha256(image),image,box:reviewedLips(files,key,image)?.box};
59
+ }
60
+ export async function setFaceCalibration(files:WorldFiles,key:string,target:Target,revision:string,imageHash:string,box:Box,open:boolean) {
61
+ FaceRevision.parse(revision);FaceRevision.parse(imageHash);LipBox.parse(box);
62
+ const current=await inspectFaceCalibration(files,key,target);
63
+ if(!current.image || current.revision!==revision || current.imageHash!==imageHash || sha256(fs.readFileSync(files.path('cast',key,'face-candidate.json')))!==revision)throw new Error('Face calibration changed; inspect it again');
64
+ files.write(files.path('cast',key,'lip-reviews',`${imageHash}.json`),{box,open});
65
+ }
@@ -0,0 +1,21 @@
1
+ import sharp from 'sharp';
2
+ import type { Box, FacePose } from '../play/lipsync.ts';
3
+
4
+ /** The cut-out PNG is cropped; the rig retains its padded paint canvas. Their dimensions are not equivalent. */
5
+ export async function alphaBounds(image: Buffer): Promise<Box> {
6
+ const { data, info } = await sharp(image).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
7
+ let left = info.width, top = info.height, right = -1, bottom = -1;
8
+ for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) if (data[(y * info.width + x) * 4 + 3] > 32) {
9
+ left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x); bottom = Math.max(bottom, y);
10
+ }
11
+ if (right < left || bottom < top) throw new Error('Cannot register an empty figure');
12
+ return [left, top, right - left + 1, bottom - top + 1];
13
+ }
14
+
15
+ export async function registerRigFace(pose: FacePose, cropped: Buffer, standing: Buffer, sheetScale = 1): Promise<FacePose> {
16
+ const [a, b] = await Promise.all([alphaBounds(cropped), alphaBounds(standing)]);
17
+ const sx = b[2] / a[2] / sheetScale, sy = b[3] / a[3] / sheetScale;
18
+ const dx = b[0] / sheetScale - a[0] * sx, dy = b[1] / sheetScale - a[1] * sy;
19
+ const transform = ([x, y, w, h]: Box): Box => [x * sx + dx, y * sy + dy, w * sx, h * sy];
20
+ return { ...pose, mouth: transform(pose.mouth), eyes: pose.eyes.map(transform), ...(pose.lips ? { lips: transform(pose.lips) } : {}), ...(pose.suggestedLips ? {suggestedLips:transform(pose.suggestedLips)} : {}) };
21
+ }
@@ -0,0 +1,71 @@
1
+ // Source-bound, resumable work. Never served as a live face until all checks pass.
2
+ import fs from 'node:fs';
3
+ import { z } from 'zod';
4
+ import { sha256, faceSourceHash } from './faces-data.ts';
5
+ import type { WorldFiles } from '../world/files.ts';
6
+ import { MOUTHS } from '../play/lipsync.ts';
7
+
8
+ export const FaceWorkId = z.string().regex(/^[a-f0-9]{32}$/);
9
+ export const FaceRevision = z.string().regex(/^[a-f0-9]{64}$/);
10
+ export const LipBox = z.tuple([z.number().min(0).max(1), z.number().min(0).max(1), z.number().positive().max(1), z.number().positive().max(1)])
11
+ .refine(([x,y,w,h]) => x+w <= 1 && y+h <= 1, 'Lip box must fit inside the image');
12
+ export const MouthName = z.enum(['X','A','B','C','D','E','F','G','H']);
13
+ const Rect = z.tuple([z.number(), z.number(), z.number(), z.number()]);
14
+ const Located = z.object({ head: Rect, mouth: Rect, eyes: z.array(Rect), confidence: z.number() });
15
+ const Work = z.object({
16
+ id: FaceWorkId, view: z.string(), sourceHash: z.string(), referenceHash: z.string(), take: z.number(),
17
+ atlas: z.string().regex(/^face-work-[a-f0-9]{64}\.png$/).optional(),
18
+ accepted: z.boolean().default(false), poses: z.array(Located.nullable()).optional(),
19
+ lips: z.record(MouthName, LipBox).default({}), reviewed: z.array(MouthName).default([]),
20
+ stage: z.enum(['atlas','articulation','lips','ready']).default('atlas'), reason: z.string().optional(),
21
+ invalidFrames: z.array(z.number().int().min(0).max(8)).default([]),
22
+ });
23
+ export type FaceWork = z.infer<typeof Work>;
24
+ const file = (files: WorldFiles, key: string, id: string) => files.path('cast', key, 'face-work', `${FaceWorkId.parse(id)}.json`);
25
+ export const workRevision = (work: FaceWork) => sha256(JSON.stringify(Work.parse(work)));
26
+ export function currentFaceSource(files: WorldFiles, key: string, view: string) {
27
+ if (view === 'image') return sha256(fs.readFileSync(files.path('cast', `${key}.png`)));
28
+ if (!/^(model:)?[a-zA-Z0-9_-]+\.png$/.test(view)) throw new Error('Invalid face view');
29
+ return faceSourceHash(files, key, view);
30
+ }
31
+ export function readFaceWork(files: WorldFiles, key: string, id: string, revision?: string): FaceWork {
32
+ const work = Work.parse(files.readJson(file(files,key,id)));
33
+ if (work.id !== id || currentFaceSource(files,key,work.view) !== work.sourceHash) throw new Error('Face source changed; prepare it again before repairing');
34
+ if (revision !== undefined && workRevision(work) !== FaceRevision.parse(revision)) throw new Error('Face work changed; inspect it again before applying this correction');
35
+ return work;
36
+ }
37
+ export function saveFaceWork(files: WorldFiles, key: string, work: FaceWork, atlas?: Buffer) {
38
+ if (atlas) {
39
+ work.atlas = `face-work-${sha256(atlas)}.png`;
40
+ files.write(files.path('cast',key,work.atlas),atlas);
41
+ }
42
+ const target = file(files,key,work.id);
43
+ files.write(`${target}.tmp`,work);
44
+ fs.renameSync(`${target}.tmp`,target);
45
+ }
46
+ export function openFaceWork(files: WorldFiles, key: string, view: string, take: number, reference: Buffer): FaceWork {
47
+ const sourceHash=currentFaceSource(files,key,view), referenceHash=sha256(reference);
48
+ const id=sha256(JSON.stringify(['face-work-v1',view,sourceHash,referenceHash,take])).slice(0,32);
49
+ if (fs.existsSync(file(files,key,id))) return readFaceWork(files,key,id);
50
+ const work=Work.parse({id,view,sourceHash,referenceHash,take});
51
+ files.write(files.path('cast',key,'face-work',`${id}-reference.png`),reference);
52
+ saveFaceWork(files,key,work);
53
+ return work;
54
+ }
55
+ export const faceWorkAtlas = (files: WorldFiles,key:string,work:FaceWork) => {
56
+ if (!work.atlas) throw new Error('No atlas yet; prepare the face first');
57
+ return fs.readFileSync(files.path('cast',key,work.atlas));
58
+ };
59
+ export const faceWorkReference = (files:WorldFiles,key:string,id:string) => fs.readFileSync(files.path('cast',key,'face-work',`${FaceWorkId.parse(id)}-reference.png`));
60
+ export function faceWorkList(files:WorldFiles,key:string) {
61
+ const dir=files.path('cast',key,'face-work');
62
+ if (!fs.existsSync(dir)) return [];
63
+ return fs.readdirSync(dir).filter(f=>/^[a-f0-9]{32}\.json$/.test(f)).flatMap(f=>{
64
+ try { const work=readFaceWork(files,key,f.slice(0,-5)); return [{id:work.id,revision:workRevision(work),view:work.view,take:work.take,stage:work.stage,reason:work.reason,accepted:work.accepted,invalidFrames:work.invalidFrames.map(i=>MOUTHS[i]),pendingLips:MOUTHS.filter(m=>!work.lips[m])}]; }
65
+ catch { return []; } // Old source-bound work is retained on disk, but cannot be applied to a repaint.
66
+ });
67
+ }
68
+
69
+ export class FacePreparationError extends Error {
70
+ constructor(readonly failures:string[]) { super(`${failures.length} face/audio preparation failures: ${failures.join('; ')}`); }
71
+ }
@@ -0,0 +1,48 @@
1
+ // Validated, source-bound face assets. A repaint disables its old registrations, never another character.
2
+ import fs from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import type { WorldFiles } from '../world/files.ts';
6
+ import { MOUTHS, type FaceDefinition } from '../play/lipsync.ts';
7
+
8
+ const Rect = z.tuple([z.number().nonnegative(), z.number().nonnegative(), z.number().positive(), z.number().positive()]);
9
+ const Pose = z.object({ mouth: Rect, eyes: z.array(Rect).max(2), lips: Rect.optional(), suggestedLips:Rect.optional(), lipConfidence:z.number().optional(), sourceOpen:z.boolean().optional(), atlas: z.string().optional() });
10
+ const Mouths = z.object(Object.fromEntries(MOUTHS.map(m => [m, Rect])) as Record<typeof MOUTHS[number], typeof Rect>);
11
+ export const FaceAsset = z.object({ image: z.string(), mouths: Mouths, atlases: z.record(Mouths).optional(), anchors: z.record(Rect).optional(), frames: z.record(Mouths).optional(),
12
+ sheets: z.record(z.array(Pose.nullable())), sheetHashes: z.record(z.string()).optional(), imagePose: Pose.optional(), rigPose: Pose.optional(), rigHash: z.string().optional(), imageHash: z.string().optional(), seed: z.number().optional(), sourceVersions: z.record(z.string()).optional() });
13
+ export const sha256 = (data: Buffer | string) => createHash('sha256').update(data).digest('hex');
14
+ export function rigFingerprint(files: WorldFiles, key: string): string | undefined {
15
+ const file = files.path('cast', key, 'rig.json');
16
+ if (!fs.existsSync(file)) return;
17
+ const rig = files.readJson(file) as { parts?: Array<{ file: string }> };
18
+ const parts = (rig.parts ?? []).map(p => files.path('cast', key, p.file));
19
+ if (parts.some(p => !fs.existsSync(p))) return;
20
+ return sha256(Buffer.concat([fs.readFileSync(file), ...parts.map(p => fs.readFileSync(p))]));
21
+ }
22
+ export function faceSourceHash(files: WorldFiles, key: string, source: string): string {
23
+ const rig = source.startsWith('rig:'), model = source.startsWith('model:');
24
+ const file = rig ? files.path('cast', key, source.slice(4)) : model ? files.path('cast', key, 'model', source.slice(6)) : files.path('cast', key, 'sprites', source);
25
+ const registration = rig ? files.path('cast', key, 'rig.json') : model ? files.path('cast', key, 'model', 'model.json') : undefined;
26
+ return sha256(Buffer.concat([fs.readFileSync(file), ...(registration ? [fs.readFileSync(registration)] : [])]));
27
+ }
28
+ const safe = (value: string) => !!value && !value.includes('..') && !value.startsWith('/') && !value.includes('\\');
29
+
30
+ export function registeredFace(files: WorldFiles, key: string, manifest = 'face.json'): { dir: string; info: FaceDefinition } | undefined {
31
+ const file = files.path('cast', key, manifest);
32
+ if (!fs.existsSync(file)) return;
33
+ try {
34
+ const info = FaceAsset.parse(files.readJson(file));
35
+ if (!safe(info.image) || !fs.existsSync(files.path('cast', key, info.image))) return;
36
+ const source = files.path('cast', `${key}.png`);
37
+ const imagePose = fs.existsSync(source) && info.imageHash === sha256(fs.readFileSync(source)) ? info.imagePose : undefined;
38
+ const rigPose = imagePose && info.rigHash === rigFingerprint(files, key) ? info.rigPose : undefined;
39
+ const sheets = Object.fromEntries(Object.entries(info.sheets).filter(([sheet]) => {
40
+ const rig = sheet.startsWith('rig:'), model = sheet.startsWith('model:');
41
+ const name = rig ? sheet.slice(4) : model ? sheet.slice(6) : sheet;
42
+ if (!safe(name)) return false;
43
+ const source = rig ? files.path('cast', key, name) : files.path('cast', key, model ? 'model' : 'sprites', name);
44
+ return fs.existsSync(source) && (!rig || fs.existsSync(files.path('cast', key, 'rig.json'))) && (!model || fs.existsSync(files.path('cast', key, 'model', 'model.json'))) && info.sheetHashes?.[sheet] === faceSourceHash(files, key, sheet);
45
+ }));
46
+ if (imagePose || Object.keys(sheets).length) return { dir: `cast/${key}`, info: { ...info, imagePose, rigPose, sheets } };
47
+ } catch { /* An unfinished face asset keeps the previous figure playable. */ }
48
+ }
@@ -0,0 +1,20 @@
1
+ // Talking mouths (the faces step) on or off for this machine. Off by default: they were barely visible in play
2
+ // and cost about half of a world's model calls and minutes of build time. DIORAMA_FACES=on|off overrides Setup.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { configDir } from '../secrets.ts';
6
+
7
+ const file = () => path.join(configDir(), 'faces.json');
8
+ const env = () => process.env.DIORAMA_FACES;
9
+
10
+ export function facesState(): { enabled: boolean; env: string | null } {
11
+ const set = env();
12
+ if (set !== undefined) return { enabled: /^(1|on|yes|true)$/i.test(set), env: set };
13
+ try { return { enabled: (JSON.parse(fs.readFileSync(file(), 'utf8')) as { enabled?: boolean }).enabled === true, env: null }; }
14
+ catch { return { enabled: false, env: null }; }
15
+ }
16
+
17
+ export function writeFacesSettings(enabled: boolean) {
18
+ fs.mkdirSync(path.dirname(file()), { recursive: true, mode: 0o700 });
19
+ fs.writeFileSync(file(), `${JSON.stringify({ enabled }, null, 2)}\n`, { mode: 0o600 });
20
+ }