@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,169 @@
|
|
|
1
|
+
// The critic: Spark looks at the painting and names up to four regions to fix; Muse repaints
|
|
2
|
+
// the picture once, and only those regions are taken from it (Muse redraws everything on an edit).
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { Memo } from '../meta/memo.ts';
|
|
6
|
+
import { museEdit } from '../meta/muse.ts';
|
|
7
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
8
|
+
import type { Place } from '../world/schema.ts';
|
|
9
|
+
import { paintedAnimals, type Brief } from './brief.ts';
|
|
10
|
+
import { FRAME } from './layout.ts';
|
|
11
|
+
import { pasteAlpha } from './plates.ts';
|
|
12
|
+
|
|
13
|
+
export const Critique = z.object({
|
|
14
|
+
issues: z.array(z.object({
|
|
15
|
+
key: z.string(),
|
|
16
|
+
problem: z.enum(['missing', 'wrong', 'flat', 'unwanted']),
|
|
17
|
+
region: z.tuple([z.number(), z.number(), z.number(), z.number()]).describe('x0, y0, x1, y1'),
|
|
18
|
+
fix: z.string(),
|
|
19
|
+
})).max(4),
|
|
20
|
+
});
|
|
21
|
+
export type Critique = z.infer<typeof Critique>;
|
|
22
|
+
|
|
23
|
+
export async function critique(memo: Memo, place: Place, brief: Brief, painting: Buffer): Promise<Critique> {
|
|
24
|
+
const expected = place.things.filter((t) => t.role !== 'npc').map((t) => `- ${t.key}: ${brief.looks[t.key] ?? t.looks}`);
|
|
25
|
+
// The place's people stand in the game as figures of their own: one also painted into the background is a double.
|
|
26
|
+
const cast = place.things.filter((t) => t.role === 'npc').map((t) => t.looks ?? t.key.replace(/-/g, ' '));
|
|
27
|
+
const prompt = `This is a painted background (${FRAME.width}x${FRAME.height} px) for a 2D point-and-click adventure. It should show:
|
|
28
|
+
${expected.join('\n')}
|
|
29
|
+
The whole picture is meant to be: ${place.shows}
|
|
30
|
+
|
|
31
|
+
List at most 4 problems a player would notice, worst first:
|
|
32
|
+
- missing: a listed thing is not there, or a big part of the scene above is (a tall tower, a castle with two towers, a house on top): key "scene" for that
|
|
33
|
+
- wrong: it is there but does not look like its description
|
|
34
|
+
- flat: it is a plain block or smear instead of a painted object
|
|
35
|
+
- unwanted: people (any person or figure at all${cast.length ? `, even one that looks like ${cast.join(' or ')}: they are added over the picture later, so a painted one would be a double` : ''}), ${paintedAnimals(brief).length ? 'animals other than the listed ones' : 'animals'}, text, letters or a border that should not be there
|
|
36
|
+
For each give the region to repaint as [x0, y0, x1, y1] in pixels and one sentence on what to paint there. Never ask for text or letters. If the painting is fine, give no issues.
|
|
37
|
+
|
|
38
|
+
Never ask to paint a person in: the game adds its people over the picture.
|
|
39
|
+
|
|
40
|
+
Reply with JSON only: {"issues": [{"key": "...", "problem": "missing | wrong | flat | unwanted", "region": [0, 0, 0, 0], "fix": "..."}]}`;
|
|
41
|
+
const parsed = Critique.parse(await askSparkJson(memo, { schema: Critique, label: `critic:${place.key}`, prompt, images: [painting] }));
|
|
42
|
+
const found = { issues: parsed.issues.filter((i) => !addsPerson(i)) };
|
|
43
|
+
// The critic weighs the whole picture and lets a figure pass when the place's words name one ("a child floats
|
|
44
|
+
// candle boats"); people are asked about on their own, as every one of them is a double or a stranger.
|
|
45
|
+
const people = await paintedPeople(memo, place.key, painting);
|
|
46
|
+
const near = (a: number[], b: number[]) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) < 120;
|
|
47
|
+
const extra = people.filter((p) => !found.issues.some((i) => i.problem === 'unwanted' && near(i.region, p.box))).map((p) => ({
|
|
48
|
+
key: 'person', problem: 'unwanted' as const, region: pad(p.box, 24),
|
|
49
|
+
fix: `Paint out the ${p.what}: continue what is behind them (planks, water, wall), add nothing new`,
|
|
50
|
+
}));
|
|
51
|
+
return { issues: [...found.issues, ...extra].slice(0, 6) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PERSON = /\b(child|children|kid|boy|girl|man|woman|men|women|person|people|figure|keeper|seller|vendor|fisherman|sailor|someone|crowd|silhouette)s?\b/i;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A fix that would paint a person in: the place's words often name its people ("a child sets candle boats on the
|
|
58
|
+
* water"), and the critic asks for them as missing, but the game draws its people over the picture itself.
|
|
59
|
+
*/
|
|
60
|
+
export const addsPerson = (i: { problem: string; fix: string }) => i.problem !== 'unwanted' && /\b(paint|add|show|draw)\b/i.test(i.fix) && PERSON.test(i.fix);
|
|
61
|
+
|
|
62
|
+
const People = z.object({ people: z.array(z.object({ what: z.string(), box: z.tuple([z.number(), z.number(), z.number(), z.number()]) })).max(6) });
|
|
63
|
+
|
|
64
|
+
/** Every person or figure painted in, with a box: none belongs in a background, the game adds its people itself. */
|
|
65
|
+
export async function paintedPeople(memo: Memo, key: string, painting: Buffer): Promise<Array<{ what: string; box: [number, number, number, number] }>> {
|
|
66
|
+
const prompt = `This is a painted background (${FRAME.width}x${FRAME.height} px) for a 2D adventure. It must show no people at all.
|
|
67
|
+
List every person or human figure painted in it: adults, children, sitting, crouching or far away, a silhouette in a window or a doorway. Not statues, scarecrows, posters or pictures of people.
|
|
68
|
+
For each give what it is in a few words ("a crouching child in a grey hoodie") and its box [x0, y0, x1, y1] in pixels, tight around the figure.
|
|
69
|
+
Reply with JSON only: {"people": [{"what": "...", "box": [0, 0, 0, 0]}]}`;
|
|
70
|
+
try {
|
|
71
|
+
const { people } = People.parse(await askSparkJson(memo, { schema: People, label: `people:${key}`, prompt, images: [painting] }));
|
|
72
|
+
return people.filter((p) => p.box[2] - p.box[0] >= 12 && p.box[3] - p.box[1] >= 20);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pad = (b: [number, number, number, number], n: number): [number, number, number, number] =>
|
|
79
|
+
[Math.max(0, b[0] - n), Math.max(0, b[1] - n), Math.min(FRAME.width, b[2] + n), Math.min(FRAME.height, b[3] + n)];
|
|
80
|
+
|
|
81
|
+
/** A soft-edged alpha mask covering the regions, so patches blend into the painting. */
|
|
82
|
+
function regionAlpha(regions: [number, number, number, number][], feather = 14): Promise<Buffer> {
|
|
83
|
+
const { width: W, height: H } = FRAME;
|
|
84
|
+
const clamp = (v: number, hi: number) => Math.max(0, Math.min(hi, v));
|
|
85
|
+
const rects = regions.map(([x0, y0, x1, y1]) => {
|
|
86
|
+
const [l, t, r, b] = [clamp(Math.min(x0, x1), W), clamp(Math.min(y0, y1), H), clamp(Math.max(x0, x1), W), clamp(Math.max(y0, y1), H)];
|
|
87
|
+
return `<rect x="${l}" y="${t}" width="${r - l}" height="${b - t}" fill="#fff"/>`;
|
|
88
|
+
});
|
|
89
|
+
return sharp(Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect width="${W}" height="${H}" fill="#000"/>${rects.join('')}</svg>`))
|
|
90
|
+
.blur(feather).grayscale().raw().toBuffer();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Repaints the critique's regions. Returns the painting unchanged when there is nothing to fix. */
|
|
94
|
+
export async function repaint(memo: Memo, place: Place, painting: Buffer, found: Critique): Promise<Buffer> {
|
|
95
|
+
if (found.issues.length === 0) return painting;
|
|
96
|
+
const fixes = found.issues.map((i) => `- around x ${Math.round((i.region[0] + i.region[2]) / 2)}, y ${Math.round((i.region[1] + i.region[3]) / 2)}: ${i.fix}`);
|
|
97
|
+
const prompt = `Repaint only these spots of this background and leave everything else exactly as it is, same style and light:
|
|
98
|
+
${fixes.join('\n')}
|
|
99
|
+
No people, no animals the fixes don't ask for, no text or letters.`;
|
|
100
|
+
const patched = await museEdit(memo, { label: `repaint:${place.key}`, prompt, images: [painting] });
|
|
101
|
+
// A painting out (people, a border) and a person's own repaint are pasted as asked; the critic's other fixes are
|
|
102
|
+
// grown to all Muse painted for them, or left out when that runs on past REGION_REACH and would be sliced.
|
|
103
|
+
const asAsked = (i: Critique['issues'][number]) => i.problem === 'unwanted' || i.key === 'person';
|
|
104
|
+
const own = found.issues.filter((i) => !asAsked(i));
|
|
105
|
+
const { kept, dropped } = await critiqueRegions(painting, patched, own.map((i) => i.region));
|
|
106
|
+
for (const r of dropped) {
|
|
107
|
+
const i = own.find((o) => o.region === r);
|
|
108
|
+
console.log(` ${place.key}: left out the repaint of ${i?.key} at [${r.join(', ')}]: Muse painted it far past its region, and pasted it would be sliced at the edge`);
|
|
109
|
+
}
|
|
110
|
+
return pasteAlpha(painting, patched, await regionAlpha([...found.issues.filter(asAsked).map((i) => i.region), ...kept]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A critic's regions grown to all Muse painted for them; one still cut at REGION_REACH is dropped, as it was asked. */
|
|
114
|
+
export async function critiqueRegions(painting: Buffer, patched: Buffer, regions: [number, number, number, number][]): Promise<{ kept: [number, number, number, number][]; dropped: [number, number, number, number][] }> {
|
|
115
|
+
const grown = await grownRegions(painting, patched, regions);
|
|
116
|
+
return { kept: grown.filter((g) => !g.cut).map((g) => g.region), dropped: regions.filter((_, i) => grown[i].cut) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `patched` over `painting` inside the regions only (feathered), everything else untouched. */
|
|
120
|
+
export async function pasteRegions(painting: Buffer, patched: Buffer, regions: [number, number, number, number][]): Promise<Buffer> {
|
|
121
|
+
return pasteAlpha(painting, patched, await regionAlpha(await wholeRegions(painting, patched, regions)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** How far a region may grow to take in the rest of what Muse painted across its edge. */
|
|
125
|
+
const REGION_REACH = 160;
|
|
126
|
+
const REGION_STEP = 12;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The regions grown, side by side, while what Muse painted runs on across that side: a boat it moved a little to
|
|
130
|
+
* the right was cut at the region's edge and faded into the sea. A side grows while the change just outside it is
|
|
131
|
+
* well above the change Muse made everywhere (it redraws the whole picture a little), up to REGION_REACH px.
|
|
132
|
+
*/
|
|
133
|
+
export async function wholeRegions(painting: Buffer, patched: Buffer, regions: [number, number, number, number][]): Promise<[number, number, number, number][]> {
|
|
134
|
+
return (await grownRegions(painting, patched, regions)).map((g) => g.region);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Each region grown as wholeRegions does, and whether Muse's change still runs on across a side at REGION_REACH. */
|
|
138
|
+
async function grownRegions(painting: Buffer, patched: Buffer, regions: [number, number, number, number][]): Promise<{ region: [number, number, number, number]; cut: boolean }[]> {
|
|
139
|
+
const { width: W, height: H } = FRAME;
|
|
140
|
+
const read = (b: Buffer) => sharp(b).resize(W, H, { fit: 'fill' }).removeAlpha().blur(3).raw().toBuffer();
|
|
141
|
+
const [a, b] = await Promise.all([read(painting), read(patched)]);
|
|
142
|
+
const diff = new Float32Array(W * H);
|
|
143
|
+
for (let i = 0; i < W * H; i++) diff[i] = Math.abs(a[i * 3] - b[i * 3]) + Math.abs(a[i * 3 + 1] - b[i * 3 + 1]) + Math.abs(a[i * 3 + 2] - b[i * 3 + 2]);
|
|
144
|
+
const sorted = Float32Array.from(diff).sort();
|
|
145
|
+
const enough = Math.max(70, 3 * sorted[Math.floor(sorted.length / 2)]);
|
|
146
|
+
// The mean change over a strip of the frame.
|
|
147
|
+
const strip = (x0: number, y0: number, x1: number, y1: number) => {
|
|
148
|
+
let [s, n] = [0, 0];
|
|
149
|
+
for (let y = Math.max(0, y0); y < Math.min(H, y1); y++) for (let x = Math.max(0, x0); x < Math.min(W, x1); x++) [s, n] = [s + diff[y * W + x], n + 1];
|
|
150
|
+
return n ? s / n : 0;
|
|
151
|
+
};
|
|
152
|
+
return regions.map((r) => {
|
|
153
|
+
let [x0, y0, x1, y1] = [Math.min(r[0], r[2]), Math.min(r[1], r[3]), Math.max(r[0], r[2]), Math.max(r[1], r[3])];
|
|
154
|
+
const [l, t, rr, bb] = [x0, y0, x1, y1];
|
|
155
|
+
// Only a side the change crosses: changed just inside the region's own edge, and just outside it.
|
|
156
|
+
const crosses = { r: strip(x1 - REGION_STEP, y0, x1, y1) > enough, l: strip(x0, y0, x0 + REGION_STEP, y1) > enough, b: strip(x0, y1 - REGION_STEP, x1, y1) > enough, t: strip(x0, y0, x1, y0 + REGION_STEP) > enough };
|
|
157
|
+
for (let k = 0; k < REGION_REACH / REGION_STEP; k++) {
|
|
158
|
+
let grew = false;
|
|
159
|
+
if (crosses.r && x1 < W && x1 - rr < REGION_REACH && strip(x1, y0, x1 + REGION_STEP, y1) > enough) [x1, grew] = [x1 + REGION_STEP, true];
|
|
160
|
+
if (crosses.l && x0 > 0 && l - x0 < REGION_REACH && strip(x0 - REGION_STEP, y0, x0, y1) > enough) [x0, grew] = [x0 - REGION_STEP, true];
|
|
161
|
+
if (crosses.b && y1 < H && y1 - bb < REGION_REACH && strip(x0, y1, x1, y1 + REGION_STEP) > enough) [y1, grew] = [y1 + REGION_STEP, true];
|
|
162
|
+
if (crosses.t && y0 > 0 && t - y0 < REGION_REACH && strip(x0, y0 - REGION_STEP, x1, y0) > enough) [y0, grew] = [y0 - REGION_STEP, true];
|
|
163
|
+
if (!grew) break;
|
|
164
|
+
}
|
|
165
|
+
const cut = (crosses.r && x1 < W && strip(x1, y0, x1 + REGION_STEP, y1) > enough) || (crosses.l && x0 > 0 && strip(x0 - REGION_STEP, y0, x0, y1) > enough)
|
|
166
|
+
|| (crosses.b && y1 < H && strip(x0, y1, x1, y1 + REGION_STEP) > enough) || (crosses.t && y0 > 0 && strip(x0, y0 - REGION_STEP, x1, y0) > enough);
|
|
167
|
+
return { region: [Math.max(0, x0), Math.max(0, y0), Math.min(W, x1), Math.min(H, y1)] as [number, number, number, number], cut };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// depthmap: how far every pixel of the painting is, so a person's shadow can follow the steps, climb a wall
|
|
2
|
+
// and drop off a quay instead of lying flat like a sticker. Depth Anything V2 Small (ONNX, on the CPU, ~1 s)
|
|
3
|
+
// gives relative depth; it is pinned to the place's own floor, whose distance we already know from the
|
|
4
|
+
// horizon: on the walk cells the floor is one plane, so its disparity grows with y - horizonY. A robust line
|
|
5
|
+
// through the walk cells turns the model's numbers into "the floor row at the same distance" for every pixel.
|
|
6
|
+
// The runtime unprojects that to 3D (camera height from scale.pxPerY) and casts shadows on it.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import sharp from 'sharp';
|
|
10
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
11
|
+
// depth.png is half the frame (the model sees ~518 px across, more pixels would only store blur) and holds
|
|
12
|
+
// rows below the horizon * DEPTH_UNIT as R (high byte) and G (low byte).
|
|
13
|
+
import { DEPTH_SCALE, DEPTH_UNIT } from '../play/shadow.ts';
|
|
14
|
+
import { FRAME } from './layout.ts';
|
|
15
|
+
|
|
16
|
+
const MODEL_URL = 'https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main/onnx/model_quantized.onnx';
|
|
17
|
+
const MODEL_FILE = path.resolve('.diorama/models/depth-anything-v2-small-q8.onnx');
|
|
18
|
+
/** The model's input: multiples of its 14 px patches, at the frame's aspect. */
|
|
19
|
+
const IN_W = 518;
|
|
20
|
+
const IN_H = 392;
|
|
21
|
+
/** A fit that explains fewer walk cells than this is not the floor: no depth map, shadows stay flat. */
|
|
22
|
+
const MIN_INLIERS = 0.35;
|
|
23
|
+
|
|
24
|
+
export type DepthMap = { png: Buffer; inliers: number; cells: number };
|
|
25
|
+
|
|
26
|
+
async function modelPath(): Promise<string> {
|
|
27
|
+
if (fs.existsSync(MODEL_FILE)) return MODEL_FILE;
|
|
28
|
+
const res = await fetch(MODEL_URL);
|
|
29
|
+
if (!res.ok) throw new Error(`depth model: HTTP ${res.status} from ${MODEL_URL}`);
|
|
30
|
+
fs.mkdirSync(path.dirname(MODEL_FILE), { recursive: true });
|
|
31
|
+
fs.writeFileSync(`${MODEL_FILE}.part`, Buffer.from(await res.arrayBuffer()));
|
|
32
|
+
fs.renameSync(`${MODEL_FILE}.part`, MODEL_FILE);
|
|
33
|
+
return MODEL_FILE;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const disparities = new WeakMap<Buffer, Promise<Float32Array>>();
|
|
37
|
+
|
|
38
|
+
/** Relative inverse depth (bigger = nearer) at the model's resolution; once per painting buffer. */
|
|
39
|
+
function disparity(painting: Buffer): Promise<Float32Array> {
|
|
40
|
+
let d = disparities.get(painting);
|
|
41
|
+
if (!d) disparities.set(painting, (d = runModel(painting)));
|
|
42
|
+
return d;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runModel(painting: Buffer): Promise<Float32Array> {
|
|
46
|
+
// The WASM build runs in Node with nothing to install natively (no CUDA download on Linux).
|
|
47
|
+
const ort = await import('onnxruntime-web');
|
|
48
|
+
const raw = await sharp(painting).resize(IN_W, IN_H, { fit: 'fill' }).removeAlpha().raw().toBuffer();
|
|
49
|
+
const mean = [0.485, 0.456, 0.406];
|
|
50
|
+
const std = [0.229, 0.224, 0.225];
|
|
51
|
+
const n = IN_W * IN_H;
|
|
52
|
+
const input = new Float32Array(3 * n);
|
|
53
|
+
for (let i = 0; i < n; i++) for (let c = 0; c < 3; c++) input[c * n + i] = (raw[i * 3 + c] / 255 - mean[c]) / std[c];
|
|
54
|
+
const session = await ort.InferenceSession.create(new Uint8Array(fs.readFileSync(await modelPath())));
|
|
55
|
+
const out = await session.run({ [session.inputNames[0]]: new ort.Tensor('float32', input, [1, 3, IN_H, IN_W]) });
|
|
56
|
+
return out[session.outputNames[0]].data as Float32Array;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Bilinear sample of the model's grid at a frame pixel. */
|
|
60
|
+
function sampleAt(d: Float32Array, x: number, y: number): number {
|
|
61
|
+
const fx = Math.min(IN_W - 1.001, Math.max(0, ((x + 0.5) * IN_W) / FRAME.width - 0.5));
|
|
62
|
+
const fy = Math.min(IN_H - 1.001, Math.max(0, ((y + 0.5) * IN_H) / FRAME.height - 0.5));
|
|
63
|
+
const [x0, y0] = [Math.floor(fx), Math.floor(fy)];
|
|
64
|
+
const [u, v] = [fx - x0, fy - y0];
|
|
65
|
+
const at = (xx: number, yy: number) => d[yy * IN_W + xx];
|
|
66
|
+
return (at(x0, y0) * (1 - u) + at(x0 + 1, y0) * u) * (1 - v) + (at(x0, y0 + 1) * (1 - u) + at(x0 + 1, y0 + 1) * u) * v;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The line d = a * (y - horizonY) + b that most walk cells agree with (RANSAC, then least squares on its
|
|
71
|
+
* inliers). Cells on raised floors or stairs disagree and are left out, so the main floor sets the scale.
|
|
72
|
+
*/
|
|
73
|
+
export function fitFloor(samples: Array<[number, number]>, seed = 1): { a: number; b: number; inliers: number } | undefined {
|
|
74
|
+
if (samples.length < 8) return undefined;
|
|
75
|
+
let rnd = seed;
|
|
76
|
+
const random = () => ((rnd = (rnd * 16807) % 2147483647) / 2147483647);
|
|
77
|
+
const ds = samples.map((s) => s[1]).sort((p, q) => p - q);
|
|
78
|
+
const tol = 0.05 * (ds[Math.floor(ds.length * 0.95)] - ds[Math.floor(ds.length * 0.05)] || 1);
|
|
79
|
+
let best: number[] = [];
|
|
80
|
+
for (let it = 0; it < 400; it++) {
|
|
81
|
+
const [p, q] = [samples[Math.floor(random() * samples.length)], samples[Math.floor(random() * samples.length)]];
|
|
82
|
+
if (Math.abs(p[0] - q[0]) < 40) continue;
|
|
83
|
+
const a = (q[1] - p[1]) / (q[0] - p[0]);
|
|
84
|
+
if (a <= 0) continue;
|
|
85
|
+
const b = p[1] - a * p[0];
|
|
86
|
+
const inl = samples.flatMap((s, i) => (Math.abs(s[1] - (a * s[0] + b)) < tol ? [i] : []));
|
|
87
|
+
if (inl.length > best.length) best = inl;
|
|
88
|
+
}
|
|
89
|
+
if (best.length < 2) return undefined;
|
|
90
|
+
let [sx, sy, sxx, sxy] = [0, 0, 0, 0];
|
|
91
|
+
for (const i of best) {
|
|
92
|
+
const [x, y] = samples[i];
|
|
93
|
+
sx += x; sy += y; sxx += x * x; sxy += x * y;
|
|
94
|
+
}
|
|
95
|
+
const n = best.length;
|
|
96
|
+
const a = (n * sxy - sx * sy) / (n * sxx - sx * sx);
|
|
97
|
+
const b = (sy - a * sx) / n;
|
|
98
|
+
return a > 0 ? { a, b, inliers: n / samples.length } : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Below this share of the floor's own depth gradient (per screen row), a surface stands upright: a wall, a bridge's side. */
|
|
102
|
+
export const UPRIGHT_SHARE = 0.2;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Where the painting stands upright rather than lies flat, from the depth model alone (before the scale is known):
|
|
106
|
+
* on a floor the depth changes steadily down the screen, on a wall it hardly does. The floor's own gradient is
|
|
107
|
+
* read off the floor masks. Derive never lays a walking path across an upright surface (the side of a bridge,
|
|
108
|
+
* the wall down into an underpass): from above it is thin air.
|
|
109
|
+
*/
|
|
110
|
+
export async function uprightTest(painting: Buffer, floor: { bits: Uint8Array; width: number; height: number }): Promise<(x: number, y: number) => boolean> {
|
|
111
|
+
const d = await disparity(painting);
|
|
112
|
+
const S = 24;
|
|
113
|
+
const grad = (x: number, y: number) => (sampleAt(d, x, y + S / 2) - sampleAt(d, x, y - S / 2)) / S;
|
|
114
|
+
const at = (x: number, y: number) => floor.bits[Math.min(floor.height - 1, Math.max(0, Math.round(y))) * floor.width + Math.min(floor.width - 1, Math.max(0, Math.round(x)))];
|
|
115
|
+
const samples: number[] = [];
|
|
116
|
+
for (let y = S; y < FRAME.height - S; y += 16) for (let x = 8; x < FRAME.width; x += 16) if (at(x, y - S / 2) && at(x, y + S / 2)) samples.push(grad(x, y));
|
|
117
|
+
if (samples.length < 20) return () => false;
|
|
118
|
+
samples.sort((a, b) => a - b);
|
|
119
|
+
const floorGrad = samples[Math.floor(samples.length / 2)];
|
|
120
|
+
if (!(floorGrad > 0)) return () => false;
|
|
121
|
+
return (x, y) => {
|
|
122
|
+
const g = [grad(x - 4, y), grad(x, y), grad(x + 4, y)].sort((a, b) => a - b)[1];
|
|
123
|
+
return g < UPRIGHT_SHARE * floorGrad;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** depth.png for a derived place, or undefined (with the reason) when the model's depth doesn't fit its floor. */
|
|
128
|
+
export async function depthMap(painting: Buffer, g: Geometry): Promise<DepthMap | { reason: string }> {
|
|
129
|
+
const d = await disparity(painting);
|
|
130
|
+
const hor = g.scale.horizonY;
|
|
131
|
+
const samples: Array<[number, number]> = [];
|
|
132
|
+
g.walk.forEach((row, r) => [...row].forEach((ch, c) => {
|
|
133
|
+
const [x, y] = [(c + 0.5) * g.cell, (r + 0.5) * g.cell];
|
|
134
|
+
if (ch === '#' && y > hor + 8) samples.push([y - hor, sampleAt(d, x, y)]);
|
|
135
|
+
}));
|
|
136
|
+
const fit = fitFloor(samples);
|
|
137
|
+
if (!fit || fit.inliers < MIN_INLIERS) return { reason: `depth fits the floor on ${Math.round((fit?.inliers ?? 0) * 100)}% of walk cells` };
|
|
138
|
+
const [w, h] = [FRAME.width / DEPTH_SCALE, FRAME.height / DEPTH_SCALE];
|
|
139
|
+
const rgb = Buffer.alloc(w * h * 3);
|
|
140
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
141
|
+
// The floor row (below the horizon) at this pixel's distance; the sky and the far bank sit just under it.
|
|
142
|
+
const rows = (sampleAt(d, x * DEPTH_SCALE + 0.5, y * DEPTH_SCALE + 0.5) - fit.b) / fit.a;
|
|
143
|
+
const v = Math.round(Math.min(65535, Math.max(1, rows * DEPTH_UNIT)));
|
|
144
|
+
rgb[(y * w + x) * 3] = v >> 8;
|
|
145
|
+
rgb[(y * w + x) * 3 + 1] = v & 255;
|
|
146
|
+
}
|
|
147
|
+
const png = await sharp(rgb, { raw: { width: w, height: h, channels: 3 } }).png({ compressionLevel: 9 }).toBuffer();
|
|
148
|
+
return { png, inliers: fit.inliers, cells: samples.length };
|
|
149
|
+
}
|