@gtanczyk/diorama 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +289 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +142 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +142 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +36 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +192 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +90 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +91 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +31 -0
- package/app/src/scene/cast.ts +192 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +55 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +417 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +554 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +421 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +677 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1328 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3751 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// Locate faces, paint their reusable articulations and bind them to unchanged source graphics.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { sourceLipCrop, reviewedLips } from './face-calibration.ts';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
8
|
+
import { museEdit } from '../meta/muse.ts';
|
|
9
|
+
import { Rig, swingPose } from '../play/rig.ts';
|
|
10
|
+
import { MOUTHS, offsetFace, type Box, type FaceDefinition, type FacePose, type Mouth } from '../play/lipsync.ts';
|
|
11
|
+
import { registeredFace, sha256, faceSourceHash, rigFingerprint } from './faces-data.ts';
|
|
12
|
+
import { alphaBounds, registerRigFace } from './face-registration.ts';
|
|
13
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
14
|
+
import { openFaceWork, saveFaceWork, readFaceWork, faceWorkAtlas, faceWorkReference, FaceRevision, LipBox } from './face-work.ts';
|
|
15
|
+
|
|
16
|
+
const Rect = z.tuple([z.number().min(0).max(1), z.number().min(0).max(1), z.number().positive().max(1), z.number().positive().max(1)]);
|
|
17
|
+
const Located = z.object({ head: Rect, mouth: Rect, eyes: z.array(Rect).max(2), confidence: z.number().min(0).max(1) });
|
|
18
|
+
const LocatedGrid = z.object({ poses: z.array(Located.nullable()), matches: z.boolean(), articulation: z.boolean(), reason: z.string(), invalidFrames: z.array(z.number().int().min(0).max(8)).max(9).optional() });
|
|
19
|
+
class AtlasRejected extends Error {
|
|
20
|
+
constructor(readonly reason: string, readonly frames: number[]) { super(`face atlas rejected: ${reason}`); }
|
|
21
|
+
}
|
|
22
|
+
type Located = z.infer<typeof Located>;
|
|
23
|
+
const FACE_VERSION = 'lip-square-v3';
|
|
24
|
+
const SPRITE_VERSION = 'individual-alpha-heads-v5';
|
|
25
|
+
type Grid = { cols: number; rows: number; count: number };
|
|
26
|
+
const validBox = ([x, y, w, h]: Box) => x + w <= 1.005 && y + h <= 1.005;
|
|
27
|
+
export const pixels = ([x, y, w, h]: Box, width: number, height: number): Box => [x * width, y * height, w * width, h * height];
|
|
28
|
+
|
|
29
|
+
async function locate(files: WorldFiles, key: string, image: Buffer, grid: Grid, reference?: Buffer, allow: number[] = []): Promise<Array<Located | null>> {
|
|
30
|
+
if (!reference && grid.count > 1) {
|
|
31
|
+
const meta=await sharp(image).metadata(),cw=meta.width!/grid.cols,ch=meta.height!/grid.rows,poses:Array<Located|null>=[];
|
|
32
|
+
for(let first=0;first<grid.count;first+=3)await Promise.all(Array.from({length:Math.min(3,grid.count-first)},async(_,n)=>{
|
|
33
|
+
const i=first+n,cell=await sharp(image).extract(cropRect([i%grid.cols*cw,Math.floor(i/grid.cols)*ch,cw,ch],meta.width!,meta.height!)).png().toBuffer();
|
|
34
|
+
const box=await alphaBounds(cell);
|
|
35
|
+
box[3]=Math.min(box[3],Math.max(box[2]*0.9,box[3]*0.38));
|
|
36
|
+
const rect=cropRect(box,Math.floor(cw),Math.floor(ch));
|
|
37
|
+
const crop=await sharp(cell).extract(rect).resize(512,512,{fit:'fill'}).png().toBuffer();
|
|
38
|
+
const found=(await locate(files,`${key}:cell:${i}`,crop,{cols:1,rows:1,count:1}))[0];
|
|
39
|
+
const map=([x,y,w,h]:Box):Box=>[(rect.left+x*rect.width)/cw,(rect.top+y*rect.height)/ch,w*rect.width/cw,h*rect.height/ch];
|
|
40
|
+
poses[i]=found?{...found,head:map(found.head),mouth:map(found.mouth),eyes:found.eyes.map(map)}:null;
|
|
41
|
+
}));
|
|
42
|
+
return poses;
|
|
43
|
+
}
|
|
44
|
+
let prompt = `Locate the visible faces of this painted game character. Image 1 is a ${grid.cols} column by ${grid.rows} row sprite grid; inspect the first ${grid.count} cells in row-major order. ${reference ? 'Image 2 is the identity reference. Verify the generated heads keep its same identity, view direction and painted style, and that cells show in order: relaxed closed; pressed M/B/P; slightly open with teeth; EH open; AH wide; OH round; OO puckered; F/V upper teeth on lower lip; L tongue raised. articulation is true only if all nine requested shapes are distinguishable and in order.' : 'matches and articulation should be true; no atlas validation is required.'}
|
|
45
|
+
Return one pose per inspected cell (null if back-facing, face obscured, unrecognizable or no visible mouth). All rectangles are [x,y,width,height] normalized 0..1 RELATIVE TO THAT CELL, never to the whole sheet. head: the entire head including hat/hair, excluding neck and torso. mouth: a reusable LOWER-FACE PATCH, not a tight bounding box around the lip opening: include the moustache/skin just below the nose, both lips and the upper chin/beard, but do not cross the face silhouette. Keep this same anatomical region for every articulation even when lips are closed. eyes: up to two small rectangles including the eye, its upper lid/brow and surrounding skin; centre each rectangle on the eye. confidence: precision of these locations. Do not invent a face for a head seen from behind. Give a short reason, especially when a face or requested articulation is invalid.
|
|
46
|
+
Reply JSON only: {poses:[{head:[...],mouth:[...],eyes:[[...]],confidence:0.9}|null,...],matches:true,articulation:true,reason:"..."}.`;
|
|
47
|
+
let images = [image];
|
|
48
|
+
if (grid.count > 1 || reference) {
|
|
49
|
+
const meta = await sharp(image).metadata(), cw = meta.width! / grid.cols, ch = meta.height! / grid.rows;
|
|
50
|
+
images = await Promise.all(Array.from({ length: grid.count }, (_, i) => sharp(image).extract(cropRect([(i % grid.cols) * cw, Math.floor(i / grid.cols) * ch, cw, ch], meta.width!, meta.height!))
|
|
51
|
+
.resize({ width: 512, height: 512, fit: 'inside' }).png().toBuffer()));
|
|
52
|
+
prompt = `Inspect the first ${grid.count} images. EACH IMAGE IS ONE SEPARATE FRAME of the same painted character, with its own coordinates. Return exactly ${grid.count} poses in image order. Every box is [x,y,width,height], normalized 0..1 relative to that individual image. There is NO sprite grid in these input images. A back-facing, obscured or absent face is null. head includes the hat/hair but excludes neck and torso. mouth is the same anatomical LOWER-FACE region in each image: below the nose through the lips and upper chin/beard, including some surrounding skin; never a tight box around an opening. eyes are small regions centred on each eye, including lids/brow and surrounding skin. Confidence must reflect location precision. ${reference ? `The LAST image is only an identity reference. Check identity, view and painted style match it. The first nine images must respectively show relaxed closed, pressed M/B/P, almost-closed teeth, open EH, wide AH, round OH, puckered OO, teeth-on-lower-lip F/V, tongue-raised L. matches/articulation are true only when these checks pass.` : 'matches and articulation should be true; no atlas validation is needed.'} Reply JSON only: {poses:[{head:[...],mouth:[...],eyes:[[...]],confidence:0.95}|null,...],matches:true,articulation:true,reason:"..."}.`;
|
|
53
|
+
}
|
|
54
|
+
if (reference) prompt += '\nAlso return invalidFrames: the ZERO-BASED indices (0..8) of every frame failing identity, view, style or its requested mouth shape. F/V is index 7: upper incisors touch the lower lip, with no lower teeth row or open grin. OH is index 5: rounded opening, distinct from AH. An empty list means no invalid frames. Judge articulation appropriate to the reference view, including profiles; do not require a frontal view.';
|
|
55
|
+
const result = LocatedGrid.parse(await askSparkJson(files.memo, { schema: LocatedGrid, label: `face-locate:${key}`, prompt, images: [...images, ...(reference ? [reference] : [])], personal: true }));
|
|
56
|
+
if (result.poses.length !== grid.count) throw new Error(`face locator returned ${result.poses.length} poses, expected ${grid.count}`);
|
|
57
|
+
const invalid = [...new Set(result.invalidFrames ?? [])].filter((i) => !allow.includes(i));
|
|
58
|
+
// A frame the caller stood in for another (F/V shown with B) may fail its own articulation; nothing else may.
|
|
59
|
+
const excused = allow.length > 0 && (result.invalidFrames ?? []).some((i) => allow.includes(i)) && invalid.length === 0;
|
|
60
|
+
if (reference && (!result.matches || (!result.articulation && !excused) || invalid.length)) throw new AtlasRejected(result.reason, invalid);
|
|
61
|
+
return result.poses.map(p => p && p.confidence >= 0.6 && [p.head, p.mouth, ...p.eyes].every(validBox) ? p : null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cropRect(box: Box, width: number, height: number) {
|
|
65
|
+
const [x, y, w, h] = box;
|
|
66
|
+
const left = Math.max(0, Math.floor(x + 1e-7)), top = Math.max(0, Math.floor(y + 1e-7));
|
|
67
|
+
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)) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Register the actual lip corners, rather than stretching an invented lower face over the beard. */
|
|
71
|
+
async function calibrateLips(files: WorldFiles, key: string, data: Buffer, grid: Grid, poses: Array<FacePose | null>): Promise<boolean> {
|
|
72
|
+
const pending = poses.map((pose,i)=>({pose,i})).filter(p=>p.pose&&!p.pose.lips) as Array<{pose:FacePose;i:number}>;
|
|
73
|
+
if (!pending.length) return false;
|
|
74
|
+
const meta = await sharp(data).metadata(), cw=meta.width!/grid.cols, ch=meta.height!/grid.rows;
|
|
75
|
+
const schema=z.object({box:Rect,confidence:z.number().min(0).max(1),open:z.boolean().optional()});
|
|
76
|
+
const failures:string[]=[];
|
|
77
|
+
for(let n=0;n<pending.length;n+=3){const batch=await Promise.allSettled(pending.slice(n,n+3).map(async({pose,i})=>{
|
|
78
|
+
const {image,rect}=await sourceLipCrop(data,grid.cols,grid.rows,pose,i);
|
|
79
|
+
const reviewed=reviewedLips(files,key.split(':')[0],image);
|
|
80
|
+
const result=reviewed ? {...reviewed,confidence:1} : schema.parse(await askSparkJson(files.memo,{schema,label:`face-source:${key}:${i}`,images:[image],personal:true,
|
|
81
|
+
prompt:'This is ONE close-up of the original painted character mouth. Locate a tight box around the actual upper and lower lips and both mouth corners. A closed mouth has a thin lip line; do not include moustache, beard, nose, chin or surrounding skin. Return normalized [x,y,width,height] relative to this ONE entire image. The horizontal centre and upper lip edge must be precise for animation. Also return open:true only when the original mouth has a visible open cavity, otherwise open:false. JSON only: {"box":[0.3,0.25,0.5,0.2],"confidence":0.95}.'}));
|
|
82
|
+
if(typeof result.open==='boolean')pose.sourceOpen=result.open;
|
|
83
|
+
const[x,y,w,h]=result.box;
|
|
84
|
+
const box:Box=[rect.left-i%grid.cols*cw+x*rect.width,rect.top-Math.floor(i/grid.cols)*ch+y*rect.height,w*rect.width,h*rect.height];
|
|
85
|
+
if(result.confidence<0.7||!validBox(result.box)){
|
|
86
|
+
pose.suggestedLips=box;pose.lipConfidence=result.confidence;
|
|
87
|
+
throw new Error(`Lip corners of pose ${i} need review (${Math.round(result.confidence*100)}% confidence)`);
|
|
88
|
+
}
|
|
89
|
+
pose.lips=box;delete pose.suggestedLips;delete pose.lipConfidence;
|
|
90
|
+
}));for(const result of batch)if(result.status==='rejected')failures.push(String(result.reason instanceof Error?result.reason.message:result.reason));}
|
|
91
|
+
if(failures.length)throw new Error(failures.join('; '));
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Individual frames can drift even when a grid keeps the same head size. Align lip centres and upper rims. */
|
|
96
|
+
async function calibrateFrames(files: WorldFiles, key: string, info: FaceDefinition, atlas: Buffer): Promise<boolean> {
|
|
97
|
+
info.frames ??= {};
|
|
98
|
+
const views = [...new Set([info.imagePose?.atlas ?? 'default', ...Object.values(info.sheets).flatMap(poses => poses.filter(Boolean).map(p => p!.atlas ?? 'default'))])];
|
|
99
|
+
let changed = false;
|
|
100
|
+
const schema = z.object({ box: Rect, confidence: z.number().min(0).max(1) });
|
|
101
|
+
const meta = await sharp(atlas).metadata();
|
|
102
|
+
for (const view of views) {
|
|
103
|
+
if (info.frames[view]) continue;
|
|
104
|
+
const mouths = view === 'default' ? info.mouths : info.atlases?.[view];
|
|
105
|
+
if (!mouths) continue;
|
|
106
|
+
const frames = {} as Record<Mouth, Box>;
|
|
107
|
+
for (let i = 0; i < MOUTHS.length; i += 3) {
|
|
108
|
+
const batch=await Promise.allSettled(MOUTHS.slice(i,i+3).map(async mouth => {
|
|
109
|
+
const image = await sharp(atlas).extract(cropRect(mouths[mouth], meta.width!, meta.height!)).resize(512,512,{fit:'fill'}).png().toBuffer();
|
|
110
|
+
const reviewed=reviewedLips(files,key,image);
|
|
111
|
+
const result = reviewed ? {...reviewed,confidence:1} : schema.parse(await askSparkJson(files.memo, { schema, label:`face-frame:${key}:${view}:${mouth}`, images:[image], personal:true,
|
|
112
|
+
prompt:'This image is one close-up of a painted mouth. Locate a tight rectangle around the actual lips and the mouth opening, including both corners. Exclude beard, moustache and nose. For an open mouth include the lip rim around the dark cavity. Return normalized [x,y,width,height] relative to this one entire image. Pay special attention to the top edge of the upper lip and the horizontal centre of the mouth; they must be aligned to another painting. JSON only: {"box":[0.3,0.25,0.5,0.2],"confidence":0.95}.' }));
|
|
113
|
+
if (result.confidence < 0.7 || !validBox(result.box)) throw new Error(`Mouth frame ${mouth} could not be aligned confidently`);
|
|
114
|
+
frames[mouth] = result.box;
|
|
115
|
+
}));
|
|
116
|
+
const failed=batch.find(r=>r.status==='rejected');
|
|
117
|
+
if(failed?.status==='rejected')throw failed.reason;
|
|
118
|
+
}
|
|
119
|
+
info.frames[view] = frames;
|
|
120
|
+
info.sourceVersions ??= {}; info.sourceVersions[`frames:${view}`] = 'patches-v1';
|
|
121
|
+
info.anchors ??= {}; info.anchors[view] = frames.X;
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
return changed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Index of F/V (G) in the atlas, and of B (teeth nearly together), which stands in for it. */
|
|
128
|
+
const FV = MOUTHS.indexOf('G'), FV_STAND_IN = MOUTHS.indexOf('B');
|
|
129
|
+
|
|
130
|
+
const ATLAS_PROMPT = `The first image is a fixed 3 by 3 grid of nine copies of ONE painted character head. The second image is its identity reference. Create a production lip-sync atlas by changing ONLY the mouth and a small part of the lower jaw in each cell. Preserve the EXACT head size, position, angle, identity, eyes, eyebrows, hat, hair, moustache, beard, lighting, palette and painting texture. Keep the 3x3 layout and all head landmarks perfectly registered. The mouths must actually have these distinct shapes, in row-major order: 1 relaxed CLOSED at rest; 2 CLOSED lips pressed together for M/B/P; 3 slightly open with teeth nearly together; 4 moderately OPEN for EH; 5 conversational OPEN for AH, never shouting; 6 ROUNDED OPEN for OH; 7 small PUCKERED for OO; 8 upper teeth touching lower lip for F/V; 9 slightly open with the tongue raised for L. Keep the original small lip width, thin lip thickness, age and wrinkles. No inflated glossy lips. Conversational movement: open the lips modestly inside the original beard, keep the jaw outline fixed. Natural dark mouth interiors and restrained teeth; no smile unless required by the original character. No text, no labels, no borders, no added objects. Do not repaint the upper face or rotate the head. Keep the background plain and constant.`;
|
|
131
|
+
|
|
132
|
+
const MOUTH_DIRECTIONS = ['relaxed CLOSED lips at rest', 'CLOSED lips pressed together for M/B/P', 'slightly open lips with teeth nearly together', 'moderately OPEN for EH', 'conversational OPEN for AH, never shouting', 'ROUNDED opening for OH, visibly rounder than AH', 'small PUCKERED lips for OO', 'F/V as in saying VERY: gently BITE the LOWER LIP with the upper front teeth. Lift the lower lip UP and curl it INWARD until it physically touches the bottom edges of the upper incisors, closing the dark gap underneath them. Expose only a few upper incisor tips above that raised lip. Keep the mouth corners neutral, not pulled back. The lower lip is skin, not another row of teeth. No smile, no horizontal open cavity beneath the teeth', 'slightly open lips with tongue raised for L'];
|
|
133
|
+
|
|
134
|
+
/** Paint an atlas, then repair up to three bad cells or retry the whole atlas with diagnostic feedback. */
|
|
135
|
+
async function mouthPatches(files: WorldFiles, character: string, view: string, reference: Buffer, take: number): Promise<{patches:Buffer[];frames:Record<Mouth,Box>}> {
|
|
136
|
+
const key = `${character}:${view}`;
|
|
137
|
+
const work = openFaceWork(files, character, view, take, reference);
|
|
138
|
+
const square = await sharp(reference).resize(320, 320, { fit: 'contain', background: '#d9d9d9' }).flatten({ background: '#d9d9d9' }).png().toBuffer();
|
|
139
|
+
const template = await sharp({ create: { width: 960, height: 960, channels: 3, background: '#d9d9d9' } }).composite(MOUTHS.map((_, i) => ({ input: square, left: (i % 3) * 320, top: Math.floor(i / 3) * 320 }))).png().toBuffer();
|
|
140
|
+
let failure: unknown;
|
|
141
|
+
let rejected: Buffer | undefined;
|
|
142
|
+
// F/V is the one shape Muse keeps painting as a grin, and the briefest in speech. When it alone still fails after
|
|
143
|
+
// the repairs, the atlas is taken with F/V shown as B (teeth nearly together): eight true shapes and a near one,
|
|
144
|
+
// instead of no talking face at all.
|
|
145
|
+
const fvOnly = (e: unknown) => e instanceof AtlasRejected && e.frames.length === 1 && e.frames[0] === FV;
|
|
146
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
147
|
+
if (attempt === 2 && !(fvOnly(failure) && rejected)) break;
|
|
148
|
+
try {
|
|
149
|
+
let atlas: Buffer;
|
|
150
|
+
if (attempt === 2) {
|
|
151
|
+
const meta = await sharp(rejected!).metadata(), width = meta.width!, height = meta.height!;
|
|
152
|
+
const cell = (i: number) => { const left = Math.floor(i % 3 * width / 3), top = Math.floor(Math.floor(i / 3) * height / 3); return { left, top, width: Math.floor((i % 3 + 1) * width / 3) - left, height: Math.floor((Math.floor(i / 3) + 1) * height / 3) - top }; };
|
|
153
|
+
const from = cell(FV_STAND_IN), to = cell(FV);
|
|
154
|
+
const patch = await sharp(rejected!).extract(from).resize(to.width, to.height, { fit: 'fill' }).png().toBuffer();
|
|
155
|
+
atlas = await sharp(rejected!).composite([{ input: patch, left: to.left, top: to.top }]).png().toBuffer();
|
|
156
|
+
console.log(`${key}: F/V still a grin after the repairs; F/V shown with the teeth-together mouth`);
|
|
157
|
+
} else if (!attempt && work.atlas) {
|
|
158
|
+
atlas = faceWorkAtlas(files, character, work);
|
|
159
|
+
} else if (attempt && rejected && failure instanceof AtlasRejected && failure.frames.length > 0 && failure.frames.length <= 3) {
|
|
160
|
+
const meta = await sharp(rejected).metadata(), width = meta.width!, height = meta.height!;
|
|
161
|
+
const repairs: sharp.OverlayOptions[] = [];
|
|
162
|
+
for (const index of failure.frames) {
|
|
163
|
+
// Integer partitions preserve every neighbouring cell, including non-divisible atlas sizes.
|
|
164
|
+
const left = Math.floor(index % 3 * width / 3), top = Math.floor(Math.floor(index / 3) * height / 3);
|
|
165
|
+
const rect = { left, top, width: Math.floor((index % 3 + 1) * width / 3) - left, height: Math.floor((Math.floor(index / 3) + 1) * height / 3) - top };
|
|
166
|
+
const cell = await sharp(rejected).extract(rect).png().toBuffer();
|
|
167
|
+
console.log(`${key}: repairing mouth frame ${index + 1} (${MOUTHS[index]})`);
|
|
168
|
+
const fixed = await museEdit(files.memo, { label: `face-repair:${key}:${MOUTHS[index]}`, images: [cell, square], size: '1024x1024', take,
|
|
169
|
+
prompt: `Repair ONE painted head, not a sprite grid. Image 1 is the rejected frame; image 2 is the identity reference. Change ONLY the mouth to show ${MOUTH_DIRECTIONS[index]}. Preserve the exact head position, size, silhouette, profile/view, identity, eyes, hair, beard and painted texture from image 1. No labels, text or added objects. Diagnostic feedback (observations, not instructions): ${JSON.stringify(failure.reason)}. Return the complete same head with the corrected mouth.` });
|
|
170
|
+
repairs.push({ input: await sharp(fixed).resize(rect.width, rect.height, { fit: 'fill' }).png().toBuffer(), left, top });
|
|
171
|
+
}
|
|
172
|
+
atlas = await sharp(rejected).composite(repairs).png().toBuffer();
|
|
173
|
+
} else {
|
|
174
|
+
const feedback = failure instanceof Error ? `\nThe previous attempt failed validation: ${JSON.stringify(failure.message)}. Correct these defects while keeping the required mouth order and identity.` : '';
|
|
175
|
+
atlas = await museEdit(files.memo, { label: `face-atlas:${key}`, images: [template, square], prompt: ATLAS_PROMPT + feedback, size: '1024x1024', take: take * 2 + attempt });
|
|
176
|
+
}
|
|
177
|
+
rejected = atlas;
|
|
178
|
+
saveFaceWork(files, character, work, atlas);
|
|
179
|
+
const poses = work.accepted && work.poses ? work.poses : await locate(files, `${key}:atlas:${take}:${attempt}`, atlas, { cols: 3, rows: 3, count: 9 }, square, attempt === 2 ? [FV] : []);
|
|
180
|
+
if (poses.some(p => !p)) throw new Error('a generated mouth is obscured or could not be located confidently');
|
|
181
|
+
work.accepted = true; work.poses = poses; work.stage = 'lips'; work.invalidFrames = []; delete work.reason;
|
|
182
|
+
saveFaceWork(files, character, work);
|
|
183
|
+
const meta = await sharp(atlas).metadata(), width = meta.width!, height = meta.height!;
|
|
184
|
+
// Locate each mouth in its full head. A lower-face crop inferred from a grid can cut a lip off.
|
|
185
|
+
const cells = await Promise.all(MOUTHS.map((_,i)=>sharp(atlas).extract(cropRect([(i%3)*width/3,Math.floor(i/3)*height/3,width/3,height/3],width,height)).png().toBuffer()));
|
|
186
|
+
const schema=z.object({box:Rect,confidence:z.number().min(0).max(1)}), lips:Box[]=[];
|
|
187
|
+
// Every mouth still unlocated in one ask (one call where nine went), each head its own image. An answer that
|
|
188
|
+
// does not hold together is asked mouth by mouth as before; an unsure mouth goes to the close-up below.
|
|
189
|
+
const unlocated = MOUTHS.map((m, at) => ({ m, at })).filter(({ m }) => !work.lips[m]);
|
|
190
|
+
const heads = await Promise.all(MOUTHS.map((_, at) => sharp(cells[at]).resize(512, 512, { fit: 'fill' }).png().toBuffer()));
|
|
191
|
+
const together = new Map<Mouth, z.infer<typeof schema>>();
|
|
192
|
+
if (unlocated.length > 1) {
|
|
193
|
+
const many = z.object({ lips: z.array(schema) });
|
|
194
|
+
const answer = await askSparkJson(files.memo, { schema: many, label: `face-atlas-lips:${key}:${unlocated.map(({ m }) => m).join('')}`, images: unlocated.map(({ at }) => heads[at]), personal: true,
|
|
195
|
+
prompt: `These ${unlocated.length} images each contain ONE painted character head, each with its own coordinates. In each, locate the actual lips and mouth opening only, from corner to corner, including the upper/lower lip rim. Exclude moustache, beard, nose and chin. For a closed mouth locate its thin lip line. Return, in image order, exactly ${unlocated.length} entries: normalized [x,y,width,height] relative to that ONE full image, and confidence. JSON only: {"lips":[{"box":[0.3,0.6,0.25,0.08],"confidence":0.95}, ...]}.` }).catch(() => undefined);
|
|
196
|
+
const parsed = many.safeParse(answer);
|
|
197
|
+
if (parsed.success && parsed.data.lips.length === unlocated.length) unlocated.forEach(({ m }, n) => together.set(m, parsed.data.lips[n]));
|
|
198
|
+
}
|
|
199
|
+
for(let i=0;i<MOUTHS.length;i+=3) {
|
|
200
|
+
const batch = await Promise.allSettled(MOUTHS.slice(i,i+3).map(async(m,n)=>{
|
|
201
|
+
const at=i+n;
|
|
202
|
+
if (work.lips[m]) { lips[at] = work.lips[m]!; return; }
|
|
203
|
+
const image=heads[at];
|
|
204
|
+
let result=together.get(m) ?? schema.parse(await askSparkJson(files.memo,{schema,label:`face-atlas-lip:${key}:${m}`,images:[image],personal:true,
|
|
205
|
+
prompt:'This image contains ONE painted character head. Locate the actual lips and mouth opening only, from corner to corner, including the upper/lower lip rim. Exclude moustache, beard, nose and chin. For a closed mouth locate its thin lip line. Return normalized [x,y,width,height] relative to this ONE full image, and confidence. JSON only: {"box":[0.3,0.6,0.25,0.08],"confidence":0.95}.'}));
|
|
206
|
+
if (result.confidence < 0.7 || !LipBox.safeParse(result.box).success) {
|
|
207
|
+
// A tiny closed lip under a moustache can be lost in a whole-head inspection.
|
|
208
|
+
// Reinspect the already located lower face; never repaint accepted articulations for this.
|
|
209
|
+
const meta = await sharp(cells[at]).metadata(), w = meta.width!, h = meta.height!;
|
|
210
|
+
const [x, y, mw, mh] = pixels(poses[at]!.mouth, w, h);
|
|
211
|
+
const rect = cropRect([x - mw * .4, y - mh * .4, mw * 1.8, mh * 1.8], w, h);
|
|
212
|
+
const detail = await sharp(cells[at]).extract(rect).resize(512, 512, { fit: 'fill' }).png().toBuffer();
|
|
213
|
+
const close = schema.parse(await askSparkJson(files.memo, { schema, label: `face-atlas-lip-detail:${key}:${m}`, images: [detail], personal: true,
|
|
214
|
+
prompt: 'Locate the actual lip line/opening in this magnified LOWER FACE crop. Include both lip corners and the lip rims, but exclude moustache, beard, nose and chin. Closed lips may be a very thin line. Do not invent hidden boundaries: lower confidence if uncertain. Return box normalized to THIS CROP, not the full head, and confidence. JSON: {"box":[0.2,0.4,0.5,0.05],"confidence":0.95}.' }));
|
|
215
|
+
if (close.confidence >= .7 && LipBox.safeParse(close.box).success) {
|
|
216
|
+
const [cx, cy, cw, ch] = close.box;
|
|
217
|
+
result = { confidence: close.confidence, box: [(rect.left + cx * rect.width) / w, (rect.top + cy * rect.height) / h, cw * rect.width / w, ch * rect.height / h] };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if(result.confidence<0.7||!LipBox.safeParse(result.box).success)throw new Error(`Generated lip frame ${m} could not be located confidently`);
|
|
221
|
+
lips[at]=result.box;
|
|
222
|
+
work.lips[m] = result.box; saveFaceWork(files, character, work);
|
|
223
|
+
}));
|
|
224
|
+
const failed = batch.find(r => r.status === 'rejected');
|
|
225
|
+
if (failed?.status === 'rejected') throw failed.reason;
|
|
226
|
+
}
|
|
227
|
+
const dimensions=await Promise.all(cells.map(c=>sharp(c).metadata()));
|
|
228
|
+
const neutralWidth=lips[0][2]*dimensions[0].width!, side=Math.ceil(neutralWidth*2.2);
|
|
229
|
+
const frames={} as Record<Mouth,Box>;
|
|
230
|
+
const patches=await Promise.all(cells.map(async(cell,i)=>{
|
|
231
|
+
const w=dimensions[i].width!,h=dimensions[i].height!,[lx,ly,lw,lh]=pixels(lips[i],w,h);
|
|
232
|
+
const left=Math.round(lx+lw/2-side/2),top=Math.round(ly-neutralWidth*0.35);
|
|
233
|
+
const rect=cropRect([left,top,side,side],w,h);
|
|
234
|
+
const part=await sharp(cell).extract(rect).png().toBuffer();
|
|
235
|
+
frames[MOUTHS[i]]=[(lx-left)/side,(ly-top)/side,lw/side,lh/side];
|
|
236
|
+
const composite=await sharp({create:{width:side,height:side,channels:4,background:'#0000'}}).composite([{input:part,left:rect.left-left,top:rect.top-top}]).png().toBuffer();
|
|
237
|
+
return sharp(composite).resize(128,128).png().toBuffer();
|
|
238
|
+
}));
|
|
239
|
+
const raw = await Promise.all([patches[0], patches[4]].map(p => sharp(p).removeAlpha().raw().toBuffer()));
|
|
240
|
+
const difference = raw[0].reduce((sum, value, i) => sum + Math.abs(value - raw[1][i]), 0) / raw[0].length;
|
|
241
|
+
if (difference < 3) throw new Error('mouth patches are static; atlas registration may have selected another part of the head');
|
|
242
|
+
work.stage = 'ready'; delete work.reason; saveFaceWork(files, character, work);
|
|
243
|
+
return {patches,frames};
|
|
244
|
+
} catch (e) {
|
|
245
|
+
failure = e; work.reason = e instanceof Error ? e.message : String(e);
|
|
246
|
+
if (e instanceof AtlasRejected) { work.stage = 'articulation'; work.accepted = false; work.invalidFrames = e.frames; }
|
|
247
|
+
saveFaceWork(files, character, work);
|
|
248
|
+
if (work.accepted) throw e; // Calibration errors never repaint a validated atlas.
|
|
249
|
+
console.log(`${key}: ${work.reason}; ${attempt === 1 && fvOnly(e) ? 'F/V alone failed: standing in the teeth-together mouth' : attempt ? 'leaving existing face assets unchanged' : 'retrying with validation feedback'}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw failure;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Explicit repair of one inspected frame, with a stale-result guard and full revalidation. */
|
|
256
|
+
export async function repairFaceFrame(files: WorldFiles, key: string, id: string, revision: string, mouth: Mouth, hint: string) {
|
|
257
|
+
FaceRevision.parse(revision);
|
|
258
|
+
if (!hint.trim()) throw new Error('Describe the mouth correction');
|
|
259
|
+
const work = readFaceWork(files,key,id,revision), atlas = faceWorkAtlas(files,key,work);
|
|
260
|
+
const reference = faceWorkReference(files,key,id), index = MOUTHS.indexOf(mouth);
|
|
261
|
+
const meta = await sharp(atlas).metadata(), width = meta.width!, height = meta.height!;
|
|
262
|
+
const left=Math.floor(index%3*width/3),top=Math.floor(Math.floor(index/3)*height/3);
|
|
263
|
+
const rect={left,top,width:Math.floor((index%3+1)*width/3)-left,height:Math.floor((Math.floor(index/3)+1)*height/3)-top};
|
|
264
|
+
const cell=await sharp(atlas).extract(rect).png().toBuffer();
|
|
265
|
+
const fixed=await museEdit(files.memo,{label:`face-repair:${key}:${work.view}:${mouth}:directed`,images:[cell,reference],size:'1024x1024',
|
|
266
|
+
prompt:`Repair only the mouth in this single painted head. Image 1 is the current frame; image 2 is its identity reference. Required articulation: ${MOUTH_DIRECTIONS[index]}. Preserve head position, silhouette, eyes, view direction, beard and style. No labels or text. Creator's correction: ${JSON.stringify(hint)}.`,take:work.take});
|
|
267
|
+
const repaired=await sharp(atlas).composite([{input:await sharp(fixed).resize(rect.width,rect.height,{fit:'fill'}).png().toBuffer(),left,top}]).png().toBuffer();
|
|
268
|
+
// Recheck just before writing as well: the model call may take minutes.
|
|
269
|
+
readFaceWork(files,key,id,revision);
|
|
270
|
+
work.accepted=false; work.stage='articulation'; delete work.lips[mouth]; work.reviewed=work.reviewed.filter(m=>m!==mouth);
|
|
271
|
+
saveFaceWork(files,key,work,repaired);
|
|
272
|
+
try {
|
|
273
|
+
const poses=await locate(files,`${key}:${work.view}:directed:${mouth}`,repaired,{cols:3,rows:3,count:9},reference);
|
|
274
|
+
if(poses.some(p=>!p))throw new Error('A repaired frame could not be located confidently');
|
|
275
|
+
work.poses=poses;work.accepted=true;work.invalidFrames=[];work.stage='lips';delete work.reason;
|
|
276
|
+
} catch(error) {
|
|
277
|
+
work.reason=error instanceof Error?error.message:String(error);
|
|
278
|
+
work.invalidFrames=error instanceof AtlasRejected?error.frames:[];
|
|
279
|
+
saveFaceWork(files,key,work);throw error;
|
|
280
|
+
}
|
|
281
|
+
saveFaceWork(files,key,work);
|
|
282
|
+
return work;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Coordinates come from an inspected full-head cell, never from the whole 3x3 sheet. */
|
|
286
|
+
export function calibrateFaceFrame(files:WorldFiles,key:string,id:string,revision:string,mouth:Mouth,box:Box) {
|
|
287
|
+
FaceRevision.parse(revision); LipBox.parse(box);
|
|
288
|
+
const work=readFaceWork(files,key,id,revision);
|
|
289
|
+
if(!work.accepted)throw new Error('Atlas articulation must pass before calibrating its lips');
|
|
290
|
+
work.lips[mouth]=box;work.reviewed=[...new Set([...work.reviewed,mouth])];work.stage='lips';delete work.reason;
|
|
291
|
+
saveFaceWork(files,key,work);return work;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function prepareFace(files: WorldFiles, key: string, options: { take?: number; force?: boolean; imageOnly?: boolean; repairWorkId?: string } = {}) {
|
|
295
|
+
if (!/^[a-z0-9][\w-]*$/.test(key)) throw new Error('Invalid character key');
|
|
296
|
+
const source = files.path('cast', `${key}.png`), dir = files.path('cast', key);
|
|
297
|
+
if (!fs.existsSync(source)) throw new Error(`${key}: no painted figure`);
|
|
298
|
+
const repairWork = options.repairWorkId ? readFaceWork(files,key,options.repairWorkId) : undefined;
|
|
299
|
+
const candidate = registeredFace(files,key,'face-candidate.json');
|
|
300
|
+
const old = candidate?.info ?? registeredFace(files, key)?.info;
|
|
301
|
+
const info: FaceDefinition = old ? structuredClone(old) : { image: '', mouths: {} as Record<Mouth, Box>, sheets: {}, atlases: {}, sheetHashes: {}, seed: parseInt(sha256(key).slice(0, 6), 16) / 1000 };
|
|
302
|
+
info.atlases ??= {}; info.sheetHashes ??= {}; info.sourceVersions ??= {};
|
|
303
|
+
const layers: sharp.OverlayOptions[] = [];
|
|
304
|
+
let width = 1152, height = 0;
|
|
305
|
+
if (old) {
|
|
306
|
+
const buffer = fs.readFileSync(path.join(dir, old.image)), meta = await sharp(buffer).metadata();
|
|
307
|
+
width = Math.max(width, meta.width!); height = meta.height!;
|
|
308
|
+
layers.push({ input: buffer, left: 0, top: 0 });
|
|
309
|
+
}
|
|
310
|
+
let changed = !!candidate;
|
|
311
|
+
const skipped: string[] = [];
|
|
312
|
+
const atlasNow = () => sharp({ create: { width, height, channels: 4, background: '#0000' } }).composite(layers).png().toBuffer();
|
|
313
|
+
const checkpoint = async () => {
|
|
314
|
+
if (!changed) return;
|
|
315
|
+
const atlas = await atlasNow(), image = `face-candidate-${sha256(atlas).slice(0,12)}.png`;
|
|
316
|
+
files.write(path.join(dir,image),atlas);
|
|
317
|
+
files.write(path.join(dir,'face-candidate.json'),{...info,image});
|
|
318
|
+
};
|
|
319
|
+
const align = async (name:string,data:Buffer,grid:Grid,poses:Array<FacePose|null>) => {
|
|
320
|
+
try{return await calibrateLips(files,name,data,grid,poses);}
|
|
321
|
+
catch(error){changed=true;await checkpoint();throw error;}
|
|
322
|
+
};
|
|
323
|
+
const make = async (name: string, data: Buffer, grid: Grid) => {
|
|
324
|
+
const located = await locate(files, `${key}:${name}`, data, grid);
|
|
325
|
+
const first = located.findIndex(p => !!p);
|
|
326
|
+
if (first < 0) { skipped.push(`${name}: no visible face/mouth`); return; }
|
|
327
|
+
const meta = await sharp(data).metadata(), cw = meta.width! / grid.cols, ch = meta.height! / grid.rows;
|
|
328
|
+
const head = pixels(located[first]!.head, cw, ch);
|
|
329
|
+
head[0] += (first % grid.cols) * cw; head[1] += Math.floor(first / grid.cols) * ch;
|
|
330
|
+
const reference = repairWork?.view === name ? faceWorkReference(files,key,repairWork.id) : await sharp(data).extract(cropRect(head, meta.width!, meta.height!)).png().toBuffer();
|
|
331
|
+
const {patches,frames} = await mouthPatches(files, key, name, reference, options.take ?? 0);
|
|
332
|
+
const atlas = `${name}:${sha256(data).slice(0, 10)}:${options.take ?? 0}`;
|
|
333
|
+
const mouths = {} as Record<Mouth, Box>;
|
|
334
|
+
patches.forEach((patch, i) => { layers.push({ input: patch, left: i * 128, top: height }); mouths[MOUTHS[i]] = [i * 128, height, 128, 128]; });
|
|
335
|
+
height += 128;
|
|
336
|
+
if (!Object.keys(info.mouths).length) info.mouths = mouths;
|
|
337
|
+
info.atlases![atlas] = mouths;
|
|
338
|
+
if (info.frames) delete info.frames[atlas]; // Repaired pixels must not reuse the old patch calibration.
|
|
339
|
+
// Re-locate the final cropped patches: full-head detections are only crop proposals.
|
|
340
|
+
// A small error near a profile edge otherwise turns the crop background into a lip patch.
|
|
341
|
+
info.anchors ??= {}; info.anchors[atlas]=frames.X;
|
|
342
|
+
changed = true;
|
|
343
|
+
const previous = name === 'image' ? [info.imagePose] : info.sheets[name];
|
|
344
|
+
const sameSource = name === 'image' ? info.imageHash === sha256(data) : info.sheetHashes![name] === faceSourceHash(files,key,name);
|
|
345
|
+
return located.map((pose,i): FacePose | null => pose ? { mouth: pixels(pose.mouth, cw, ch), eyes: pose.eyes.map(e => pixels(e, cw, ch)), atlas,
|
|
346
|
+
...(sameSource && previous?.[i]?.lips ? {lips:previous[i]!.lips,sourceOpen:previous[i]!.sourceOpen} : {}) } : null);
|
|
347
|
+
};
|
|
348
|
+
const image = fs.readFileSync(source), hash = sha256(image);
|
|
349
|
+
if (!info.imagePose || info.imageHash !== hash || (info.imagePose.atlas?.startsWith('image:') && info.sourceVersions.image !== FACE_VERSION) || options.force || repairWork?.view === 'image') {
|
|
350
|
+
console.log(`${key}: locating the painted face and preparing its mouth shapes`);
|
|
351
|
+
const poses = await make('image', image, { cols: 1, rows: 1, count: 1 });
|
|
352
|
+
if (poses?.[0]) { info.imagePose = poses[0]; info.imageHash = hash; info.sourceVersions.image = FACE_VERSION; }
|
|
353
|
+
}
|
|
354
|
+
await checkpoint();
|
|
355
|
+
if(info.imagePose?.atlas!=='default' && info.sourceVersions['lips:image']!=='context-v2-fill' && !info.sourceVersions['lips:image']?.startsWith('reviewed:') && info.imagePose)delete info.imagePose.lips;
|
|
356
|
+
if (info.imagePose && !info.imagePose.lips) changed = await align(`${key}:image`, image, { cols:1, rows:1, count:1 }, [info.imagePose]) || changed;
|
|
357
|
+
if(info.imagePose?.lips && info.imagePose.atlas!=='default' && !info.sourceVersions['lips:image']?.startsWith('reviewed:'))info.sourceVersions['lips:image']='context-v2-fill';
|
|
358
|
+
await checkpoint();
|
|
359
|
+
const rigFile = path.join(dir, 'rig.json');
|
|
360
|
+
if (info.imagePose && fs.existsSync(rigFile)) {
|
|
361
|
+
const rig = Rig.parse(files.readJson(rigFile));
|
|
362
|
+
if (rig.sheet && fs.existsSync(path.join(dir, rig.sheet.file))) {
|
|
363
|
+
const [cw, ch] = rig.sheet.cell, i = rig.sheet.frames;
|
|
364
|
+
const standing = await sharp(path.join(dir, rig.sheet.file)).extract({ left: (i % rig.sheet.cols) * cw, top: Math.floor(i / rig.sheet.cols) * ch, width: cw, height: ch }).png().toBuffer();
|
|
365
|
+
const base = await registerRigFace(info.imagePose, image, standing, rig.sheet.scale);
|
|
366
|
+
const name = `rig:${rig.sheet.file}`, hash = faceSourceHash(files, key, name);
|
|
367
|
+
if (info.sheetHashes[name] !== hash || info.sheets[name]?.[0]?.atlas !== base.atlas || JSON.stringify(info.sheets[name]?.at(-1)?.lips) !== JSON.stringify(base.lips) || options.force) {
|
|
368
|
+
info.sheets[name] = [...Array(rig.sheet.frames).keys()].map(i => {
|
|
369
|
+
const torso = swingPose(rig, i / rig.sheet!.frames).torso;
|
|
370
|
+
return offsetFace(base, torso.to[0] - torso.from[0], torso.to[1] - torso.from[1]);
|
|
371
|
+
}).concat([base]);
|
|
372
|
+
info.sheetHashes[name] = hash; changed = true;
|
|
373
|
+
}
|
|
374
|
+
} else if (rig.parts.length) {
|
|
375
|
+
const fingerprint = rigFingerprint(files, key);
|
|
376
|
+
if (info.rigHash !== fingerprint || !info.rigPose?.lips || options.force) {
|
|
377
|
+
const standing = await sharp({ create: { width: rig.size[0], height: rig.size[1], channels: 4, background: '#0000' } })
|
|
378
|
+
.composite(rig.parts.map(part => ({ input: path.join(dir, part.file), left: part.at[0], top: part.at[1] }))).png().toBuffer();
|
|
379
|
+
info.rigPose = await registerRigFace(info.imagePose, image, standing);
|
|
380
|
+
info.rigHash = fingerprint; changed = true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const spritesFile = path.join(dir, 'sprites', 'sprites.json');
|
|
385
|
+
if (!options.imageOnly && fs.existsSync(spritesFile)) {
|
|
386
|
+
const sprites = files.readJson(spritesFile) as { cols: number; rows: number; talk?: Record<string, { sheet: string; gestures?: string[] }> };
|
|
387
|
+
for (const view of Object.values(sprites.talk ?? {})) {
|
|
388
|
+
const file = path.join(dir, 'sprites', view.sheet);
|
|
389
|
+
if (!fs.existsSync(file)) continue;
|
|
390
|
+
const data = fs.readFileSync(file), hash = sha256(data);
|
|
391
|
+
if (info.sheetHashes[view.sheet] === hash && info.sheets[view.sheet]?.some(Boolean) && (!info.sheets[view.sheet]?.some(p => p?.atlas && p.atlas !== 'default') || info.sourceVersions[view.sheet] === SPRITE_VERSION) && !options.force && repairWork?.view !== view.sheet) continue;
|
|
392
|
+
console.log(`${key}: preparing facial animation for ${view.sheet}`);
|
|
393
|
+
const poses = await make(view.sheet, data, { cols: sprites.cols, rows: sprites.rows, count: view.gestures?.length ?? sprites.cols * sprites.rows });
|
|
394
|
+
if (poses) { info.sheets[view.sheet] = poses; info.sheetHashes[view.sheet] = hash; info.sourceVersions[view.sheet] = SPRITE_VERSION; await checkpoint(); }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (!options.imageOnly && fs.existsSync(spritesFile)) {
|
|
398
|
+
const sprites = files.readJson(spritesFile) as { cols: number; rows: number };
|
|
399
|
+
for (const [name, poses] of Object.entries(info.sheets).filter(([name]) => !name.startsWith('rig:') && !name.startsWith('model:'))) {
|
|
400
|
+
if (poses.some(p => p && !p.lips)) changed = await align(`${key}:${name}`, fs.readFileSync(path.join(dir, 'sprites', name)), { cols:sprites.cols, rows:sprites.rows, count:poses.length }, poses) || changed;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const modelFile = path.join(dir, 'model', 'model.json');
|
|
404
|
+
if (!options.imageOnly && fs.existsSync(modelFile)) {
|
|
405
|
+
const model = files.readJson(modelFile) as { texture?: string; views: Record<string, { image: string }> };
|
|
406
|
+
// Projected paint on a real mesh can articulate without remeshing a reconstructed closed mouth.
|
|
407
|
+
if (model.texture !== 'model') for (const [view, entry] of Object.entries(model.views).filter(([view]) => view !== 'back')) {
|
|
408
|
+
const name = `model:${entry.image}`, source = path.join(dir, 'model', entry.image);
|
|
409
|
+
if (!fs.existsSync(source)) continue;
|
|
410
|
+
const hash = faceSourceHash(files,key,name);
|
|
411
|
+
if (info.sheetHashes[name] !== hash || !info.sheets[name]?.some(Boolean) || info.sourceVersions[name] !== FACE_VERSION || options.force || repairWork?.view === name) {
|
|
412
|
+
console.log(`${key}: preparing the 3D ${view} facial surface`);
|
|
413
|
+
const poses = await make(name, fs.readFileSync(source), {cols:1,rows:1,count:1});
|
|
414
|
+
if (poses) { info.sheets[name]=poses; info.sheetHashes[name]=hash; info.sourceVersions[name]=FACE_VERSION; await checkpoint(); }
|
|
415
|
+
}
|
|
416
|
+
const poses = info.sheets[name];
|
|
417
|
+
if (info.sourceVersions[`lips:${name}`] !== 'context-v2-fill' && !info.sourceVersions[`lips:${name}`]?.startsWith('reviewed:')) for (const p of poses ?? []) if(p) delete p.lips;
|
|
418
|
+
if (poses?.some(p=>p&&!p.lips)) { changed = await align(`${key}:${name}`,fs.readFileSync(source),{cols:1,rows:1,count:1},poses) || changed; info.sourceVersions[`lips:${name}`]='context-v2-fill'; }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
await checkpoint();
|
|
422
|
+
changed = await calibrateFrames(files, key, info, await atlasNow()) || changed;
|
|
423
|
+
if (changed) {
|
|
424
|
+
const atlas = await sharp({ create: { width, height, channels: 4, background: '#0000' } }).composite(layers).png().toBuffer();
|
|
425
|
+
info.image = `face-atlas-${sha256(atlas).slice(0, 12)}.png`;
|
|
426
|
+
files.write(path.join(dir, info.image), atlas);
|
|
427
|
+
files.write(path.join(dir, 'face.json.tmp'), info);
|
|
428
|
+
fs.renameSync(path.join(dir, 'face.json.tmp'), path.join(dir, 'face.json'));
|
|
429
|
+
}
|
|
430
|
+
fs.rmSync(path.join(dir,'face-candidate.json'),{force:true});
|
|
431
|
+
const status = { take: options.take ?? 0, at: new Date().toISOString(), state: info.imagePose || Object.keys(info.sheets).length ? 'ready' : 'unsupported', skipped };
|
|
432
|
+
files.write(path.join(dir, 'face-attempt.json'), status);
|
|
433
|
+
console.log(`${key}: ${changed ? 'face prepared' : 'face unchanged'} · image ${!!info.imagePose} · ${Object.keys(info.sheets).length} registered sheets${skipped.length ? ` · ${skipped.join('; ')}` : ''}`);
|
|
434
|
+
return { ...status, changed };
|
|
435
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Where the painted lights really are. layout.lights are guessed before the painting exists, and Muse paints a
|
|
2
|
+
// lamp where the picture wants it: a lighthouse lamp on its tower 170 px above the planned point, windows a
|
|
3
|
+
// row higher. The runtime glows (src/play/glow.ts) sat on the planned points, so a lighthouse beam swept out of
|
|
4
|
+
// a path or the water. Here each light is moved onto the bright spot it was painted as: a blob much brighter
|
|
5
|
+
// than what is round it, near its planned point (or on the thing its name says holds it). A light with no
|
|
6
|
+
// painted spot near it is left out: better no glow than one on nothing. Pixels only, no model call.
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import { glowKind } from '../play/glow.ts';
|
|
9
|
+
import type { Mask } from '../image/mask.ts';
|
|
10
|
+
|
|
11
|
+
/** How far from its planned point a painted light is looked for (px). */
|
|
12
|
+
export const GLOW_REACH = 220;
|
|
13
|
+
/** A spot larger than this share of the frame is a lit wall or the sky, not a light. */
|
|
14
|
+
const MAX_SHARE = 0.015;
|
|
15
|
+
/** Glow pixels: this much brighter than the mean round them (0..255) and at least this bright. */
|
|
16
|
+
const CONTRAST = 34;
|
|
17
|
+
const BRIGHT = 150;
|
|
18
|
+
const STEP = 2;
|
|
19
|
+
|
|
20
|
+
export type Spot = { x: number; y: number; area: number; energy: number; box: [number, number, number, number]; rgb: [number, number, number]; peak: number };
|
|
21
|
+
export type PaintedGlow = { key: string; x: number; y: number };
|
|
22
|
+
type Light = { key: string; x: number; y: number; colour: string };
|
|
23
|
+
|
|
24
|
+
/** The bright spots of a painting, at full-frame coordinates. */
|
|
25
|
+
export async function brightSpots(painting: Buffer): Promise<{ spots: Spot[]; width: number; height: number }> {
|
|
26
|
+
const meta = await sharp(painting).metadata();
|
|
27
|
+
const width = meta.width!;
|
|
28
|
+
const height = meta.height!;
|
|
29
|
+
const w = Math.round(width / STEP);
|
|
30
|
+
const h = Math.round(height / STEP);
|
|
31
|
+
const { data } = await sharp(painting).removeAlpha().resize(w, h, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });
|
|
32
|
+
const lum = new Float32Array(w * h);
|
|
33
|
+
for (let i = 0; i < w * h; i++) lum[i] = 0.299 * data[i * 3] + 0.587 * data[i * 3 + 1] + 0.114 * data[i * 3 + 2];
|
|
34
|
+
// Mean round each pixel (a 41 px window at full size) from an integral image.
|
|
35
|
+
const sum = new Float64Array((w + 1) * (h + 1));
|
|
36
|
+
for (let y = 0; y < h; y++) {
|
|
37
|
+
let row = 0;
|
|
38
|
+
for (let x = 0; x < w; x++) {
|
|
39
|
+
row += lum[y * w + x];
|
|
40
|
+
sum[(y + 1) * (w + 1) + x + 1] = sum[y * (w + 1) + x + 1] + row;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const r = Math.round(20 / STEP);
|
|
44
|
+
const mean = (x: number, y: number) => {
|
|
45
|
+
const x0 = Math.max(0, x - r), x1 = Math.min(w, x + r + 1), y0 = Math.max(0, y - r), y1 = Math.min(h, y + r + 1);
|
|
46
|
+
return (sum[y1 * (w + 1) + x1] - sum[y0 * (w + 1) + x1] - sum[y1 * (w + 1) + x0] + sum[y0 * (w + 1) + x0]) / ((x1 - x0) * (y1 - y0));
|
|
47
|
+
};
|
|
48
|
+
const lift = new Float32Array(w * h);
|
|
49
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
50
|
+
const l = lum[y * w + x];
|
|
51
|
+
const d = l - mean(x, y);
|
|
52
|
+
if (l >= BRIGHT && d >= CONTRAST) lift[y * w + x] = d;
|
|
53
|
+
}
|
|
54
|
+
// Connected spots (8 neighbours), each weighted by how far it stands out.
|
|
55
|
+
const seen = new Uint8Array(w * h);
|
|
56
|
+
const spots: Spot[] = [];
|
|
57
|
+
const stack: number[] = [];
|
|
58
|
+
for (let i = 0; i < w * h; i++) {
|
|
59
|
+
if (!lift[i] || seen[i]) continue;
|
|
60
|
+
let n = 0, e = 0, sx = 0, sy = 0, cr = 0, cg = 0, cb = 0, peak = 0, x0 = w, y0 = h, x1 = 0, y1 = 0;
|
|
61
|
+
seen[i] = 1;
|
|
62
|
+
stack.push(i);
|
|
63
|
+
while (stack.length) {
|
|
64
|
+
const j = stack.pop()!;
|
|
65
|
+
const x = j % w, y = (j - x) / w;
|
|
66
|
+
const v = lift[j];
|
|
67
|
+
n++; e += v; sx += x * v; sy += y * v; peak = Math.max(peak, lum[j]); cr += data[j * 3]; cg += data[j * 3 + 1]; cb += data[j * 3 + 2];
|
|
68
|
+
x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y);
|
|
69
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
70
|
+
const u = x + dx, v2 = y + dy;
|
|
71
|
+
if (u < 0 || v2 < 0 || u >= w || v2 >= h) continue;
|
|
72
|
+
const k = v2 * w + u;
|
|
73
|
+
if (lift[k] && !seen[k]) { seen[k] = 1; stack.push(k); }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const area = n * STEP * STEP;
|
|
77
|
+
if (area < 12 || area > MAX_SHARE * width * height) continue;
|
|
78
|
+
spots.push({ x: (sx / e) * STEP + STEP / 2, y: (sy / e) * STEP + STEP / 2, area, energy: e * STEP * STEP, box: [x0 * STEP, y0 * STEP, (x1 + 1) * STEP, (y1 + 1) * STEP], rgb: [cr / n, cg / n, cb / n], peak });
|
|
79
|
+
}
|
|
80
|
+
return { spots, width, height };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** #rrggbb: is it a warm light (lamp, fire, window), red well above blue? */
|
|
84
|
+
const warm = (hex: string) => {
|
|
85
|
+
const n = parseInt(hex.replace('#', '').slice(0, 6).padEnd(6, 'f'), 16);
|
|
86
|
+
return ((n >> 16) & 255) - (n & 255) >= 25;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* A painted lamp, fire or lit window: warm, with a bright core, and compact. Moonlit white paint is not warm; pale
|
|
90
|
+
* foam is barely warm, and a sprawl of foam joined to painted golden rays (a lighthouse's burst over the waves) is
|
|
91
|
+
* bright and big but mostly empty inside its box: neither is a lamp.
|
|
92
|
+
*/
|
|
93
|
+
export const lamplike = (s: Spot) => {
|
|
94
|
+
const [bw, bh] = [s.box[2] - s.box[0], s.box[3] - s.box[1]];
|
|
95
|
+
const compact = Math.max(bw, bh) <= 48 || s.area / (bw * bh) >= 0.2;
|
|
96
|
+
return s.rgb[0] - s.rgb[2] >= 40 && s.peak >= 210 && compact;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** How hot a spot's paint is: its mean lightness, 0 at 150 and about 1 at 230. */
|
|
100
|
+
const heat = (s: Spot) => Math.max(0.05, (0.3 * s.rgb[0] + 0.59 * s.rgb[1] + 0.11 * s.rgb[2] - 150) / 80) ** 2;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Each light on its painted spot. A warm light (most are: lamps, fires, windows) takes the strongest warm spot
|
|
104
|
+
* near its planned point; one spot holds one light, the strongest, nearest pairs matched first. A beam is a
|
|
105
|
+
* lighthouse lamp: higher spots are favoured, and with none near its point it is looked for over
|
|
106
|
+
* the upper half of the painting. A light with no such spot keeps its planned point only
|
|
107
|
+
* when that point is on a painted thing (`holders`: standing things and walls, the lamp is on the tower), and
|
|
108
|
+
* is left out when it is on the floor, the water or the sky: that glow would light nothing painted. A cool
|
|
109
|
+
* light (a white beam, the moon) is not looked for among the spots: pale paint lit by the moon looks the same.
|
|
110
|
+
*/
|
|
111
|
+
export function placeGlows(lights: Light[], spots: Spot[], holders: Mask[] = [], frameH = 896): PaintedGlow[] {
|
|
112
|
+
const lamps = spots.map((s, si) => ({ s, si })).filter(({ s }) => lamplike(s));
|
|
113
|
+
const pairs = lights.flatMap((l, li) => (warm(l.colour) ? lamps : []).flatMap(({ s, si }) => {
|
|
114
|
+
const d = Math.hypot(s.x - l.x, s.y - l.y);
|
|
115
|
+
if (d > GLOW_REACH) return [];
|
|
116
|
+
// Strength grows slowly with size (a big lit wall should not beat a lamp), falls with distance, and rises with
|
|
117
|
+
// how white-hot the spot is: a lamp's core is near white, painted golden rays round it are only gold.
|
|
118
|
+
let score = Math.sqrt(s.energy) / (1 + (d / 120) ** 2) * heat(s);
|
|
119
|
+
if (glowKind(l.key) === 'beam') score *= 1 + 3 * Math.max(0, 1 - s.y / frameH);
|
|
120
|
+
return [{ li, si, score }];
|
|
121
|
+
})).sort((a, b) => b.score - a.score);
|
|
122
|
+
const taken = new Set<number>();
|
|
123
|
+
const placed = new Map<number, PaintedGlow>();
|
|
124
|
+
const place = (li: number, si: number) => {
|
|
125
|
+
taken.add(si);
|
|
126
|
+
placed.set(li, { key: lights[li].key, x: Math.round(spots[si].x), y: Math.round(spots[si].y) });
|
|
127
|
+
};
|
|
128
|
+
for (const p of pairs) if (!placed.has(p.li) && !taken.has(p.si)) place(p.li, p.si);
|
|
129
|
+
// A lighthouse lamp is one of a kind and sometimes painted far from its planned point (across the picture):
|
|
130
|
+
// with nothing near, the strongest free lamp in the upper half is it.
|
|
131
|
+
lights.forEach((l, li) => {
|
|
132
|
+
if (placed.has(li) || !warm(l.colour) || glowKind(l.key) !== 'beam') return;
|
|
133
|
+
const best = lamps.filter(({ s, si }) => !taken.has(si) && s.y < frameH / 2)
|
|
134
|
+
.map(({ s, si }) => ({ si, score: Math.sqrt(s.energy) * heat(s) * (1 - s.y / frameH) }))
|
|
135
|
+
.sort((a, b) => b.score - a.score)[0];
|
|
136
|
+
if (best) place(li, best.si);
|
|
137
|
+
});
|
|
138
|
+
return lights.flatMap((l, i) => {
|
|
139
|
+
if (placed.has(i)) return [placed.get(i)!];
|
|
140
|
+
const x = Math.round(l.x), y = Math.round(l.y);
|
|
141
|
+
return holders.some((m) => x >= 0 && y >= 0 && x < m.width && y < m.height && m.bits[y * m.width + x]) ? [{ key: l.key, x, y }] : [];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** derive's step: the painted glows of a place, and a line for the log. */
|
|
146
|
+
export async function paintedGlows(painting: Buffer, lights: Light[], holders: Mask[]): Promise<{ glows: PaintedGlow[]; line: string }> {
|
|
147
|
+
if (!lights.length) return { glows: [], line: '' };
|
|
148
|
+
const { spots, height } = await brightSpots(painting);
|
|
149
|
+
const glows = placeGlows(lights, spots, holders, height);
|
|
150
|
+
const moved = glows.map((g) => { const l = lights.find((x) => x.key === g.key)!; const d = Math.round(Math.hypot(g.x - l.x, g.y - l.y)); return d ? `${g.key} moved ${d} px` : `${g.key} kept`; });
|
|
151
|
+
const lost = lights.filter((l) => !glows.some((g) => g.key === l.key)).map((l) => l.key);
|
|
152
|
+
return { glows, line: `glows on the painted lights: ${moved.join(', ') || 'none'}${lost.length ? `; no painted light for ${lost.join(', ')}` : ''}` };
|
|
153
|
+
}
|