@gtanczyk/diorama 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -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 +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -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 +290 -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 +161 -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 +158 -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 +37 -0
- package/app/src/commands/language.ts +87 -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 +195 -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 +100 -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 +105 -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 +33 -0
- package/app/src/scene/cast.ts +199 -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 +109 -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 +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -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 +419 -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 +555 -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 +423 -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/language.ts +116 -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 +679 -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 +1329 -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 +3769 -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,177 @@
|
|
|
1
|
+
// carry: after a small repaint, what the repaint did not reach keeps what was measured on it.
|
|
2
|
+
// Each place step keeps the painting it last ran on (places/<p>/.ran/<step>.png). On the next run the painting
|
|
3
|
+
// is compared with it, tile by tile. A question whose region no changed tile reaches is asked about the old
|
|
4
|
+
// painting again, which the memo answers with the old answer for nothing; only what the change reaches goes
|
|
5
|
+
// to the models. A big change (a new painting) carries nothing, and the step runs as before.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import sharp from 'sharp';
|
|
9
|
+
import type { Mask } from '../image/mask.ts';
|
|
10
|
+
import type { Rect } from '../image/raster.ts';
|
|
11
|
+
import { FRAME } from './layout.ts';
|
|
12
|
+
|
|
13
|
+
const { width: W, height: H } = FRAME;
|
|
14
|
+
/** Tiles of the comparison: a changed pixel marks its whole tile. */
|
|
15
|
+
const TILE = 24;
|
|
16
|
+
/** A pixel changed when a channel moved more than this: a repaint pastes exactly, untouched pixels match, and a feathered edge is part of the change. */
|
|
17
|
+
const NOISE = 0;
|
|
18
|
+
/** Past this share of the frame the painting is taken as new: nothing is carried. */
|
|
19
|
+
export const MOST = 0.35;
|
|
20
|
+
|
|
21
|
+
export type Carry = {
|
|
22
|
+
/** The painting the step last ran on. */
|
|
23
|
+
old: Buffer;
|
|
24
|
+
/** The changed tiles, as rects. */
|
|
25
|
+
tiles: Rect[];
|
|
26
|
+
/** The share of the frame they cover. */
|
|
27
|
+
share: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const rgb = (png: Buffer) => sharp(png).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer();
|
|
31
|
+
|
|
32
|
+
/** The tiles where `b` differs from `a`. */
|
|
33
|
+
export async function changedTiles(a: Buffer, b: Buffer): Promise<Rect[]> {
|
|
34
|
+
const [pa, pb] = await Promise.all([rgb(a), rgb(b)]);
|
|
35
|
+
const tiles: Rect[] = [];
|
|
36
|
+
for (let ty = 0; ty < H; ty += TILE) {
|
|
37
|
+
for (let tx = 0; tx < W; tx += TILE) {
|
|
38
|
+
let hit = false;
|
|
39
|
+
for (let y = ty; y < Math.min(H, ty + TILE) && !hit; y++) {
|
|
40
|
+
for (let x = tx, i = (y * W + tx) * 3; x < Math.min(W, tx + TILE); x++, i += 3) {
|
|
41
|
+
if (Math.abs(pa[i] - pb[i]) > NOISE || Math.abs(pa[i + 1] - pb[i + 1]) > NOISE || Math.abs(pa[i + 2] - pb[i + 2]) > NOISE) {
|
|
42
|
+
hit = true;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (hit) tiles.push([tx, ty, Math.min(W, tx + TILE), Math.min(H, ty + TILE)]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tiles;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ranFile = (dir: string, step: string) => path.join(dir, '.ran', `${step}.png`);
|
|
54
|
+
|
|
55
|
+
/** What `step` can carry over from its last run in this place, or nothing (first run, same painting, a new painting). */
|
|
56
|
+
export async function carryFrom(dir: string, step: string, painting: Buffer): Promise<Carry | undefined> {
|
|
57
|
+
const file = ranFile(dir, step);
|
|
58
|
+
if (!fs.existsSync(file)) return undefined;
|
|
59
|
+
return carryBetween(fs.readFileSync(file), painting);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What can be carried from `old` to `now`, any two pictures of the place (two backdrops), or nothing. */
|
|
63
|
+
export async function carryBetween(old: Buffer, now: Buffer): Promise<Carry | undefined> {
|
|
64
|
+
// The same picture: the memo answers every question as it is.
|
|
65
|
+
if (old.equals(now)) return undefined;
|
|
66
|
+
const tiles = await changedTiles(old, now);
|
|
67
|
+
const share = tiles.reduce((s, [x0, y0, x1, y1]) => s + (x1 - x0) * (y1 - y0), 0) / (W * H);
|
|
68
|
+
return share > MOST ? undefined : { old, tiles, share };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keeps the painting `step` ran on in this place. Steps that ran on the same painting share one file. */
|
|
72
|
+
export function ran(dir: string, step: string, painting: Buffer) {
|
|
73
|
+
const file = ranFile(dir, step);
|
|
74
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
75
|
+
const twin = fs.readdirSync(path.dirname(file)).map((f) => path.join(path.dirname(file), f))
|
|
76
|
+
.find((f) => f !== file && f.endsWith('.png') && fs.statSync(f).size === painting.length && fs.readFileSync(f).equals(painting));
|
|
77
|
+
fs.rmSync(file, { force: true });
|
|
78
|
+
if (twin) {
|
|
79
|
+
try {
|
|
80
|
+
fs.linkSync(twin, file);
|
|
81
|
+
return;
|
|
82
|
+
} catch {
|
|
83
|
+
// No hard links here: a copy will do.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
fs.writeFileSync(file, painting);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What each place step leaves. */
|
|
90
|
+
const MADE: Record<string, string> = { masks: 'masks.json', plates: 'plates.json', derive: 'geometry.json', backdrop: 'backdrop.png' };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* For a place whose steps have not kept their painting (built before .ran/, or put back by an Undo that predates it):
|
|
94
|
+
* each step that left its output takes the painting on disk as the one it ran on. File times say nothing here (an
|
|
95
|
+
* Undo writes every file anew), and they need not: the carried questions are asked about this painting, a real one,
|
|
96
|
+
* so the worst a wrong guess costs is the model calls it would have cost anyway. Called before a repaint replaces it.
|
|
97
|
+
*/
|
|
98
|
+
export function seedRan(dir: string): string[] {
|
|
99
|
+
const file = path.join(dir, 'painting.png');
|
|
100
|
+
if (!fs.existsSync(file)) return [];
|
|
101
|
+
let painting: Buffer | undefined;
|
|
102
|
+
const seeded: string[] = [];
|
|
103
|
+
for (const [step, made] of Object.entries(MADE)) {
|
|
104
|
+
if (fs.existsSync(ranFile(dir, step)) || !fs.existsSync(path.join(dir, made))) continue;
|
|
105
|
+
ran(dir, step, (painting ??= fs.readFileSync(file)));
|
|
106
|
+
seeded.push(step);
|
|
107
|
+
}
|
|
108
|
+
// The far flats and the water were cut from the backdrop on disk.
|
|
109
|
+
if (fs.existsSync(ranFile(dir, 'backdrop')) && !fs.existsSync(ranFile(dir, 'backdrop-made')) && fs.existsSync(path.join(dir, 'backdrop.png'))) {
|
|
110
|
+
ran(dir, 'backdrop-made', fs.readFileSync(path.join(dir, 'backdrop.png')));
|
|
111
|
+
}
|
|
112
|
+
return seeded;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether the change reaches a region (a mask, a box, or several), grown by `margin` px. No region is never reached. */
|
|
116
|
+
export function reaches(carry: Carry, region: Mask | Rect | Array<Mask | Rect | undefined> | undefined, margin = 16): boolean {
|
|
117
|
+
if (!region) return false;
|
|
118
|
+
if (Array.isArray(region) && (region.length === 0 || typeof region[0] !== 'number')) return (region as Array<Mask | Rect | undefined>).some((r) => reaches(carry, r, margin));
|
|
119
|
+
for (const [tx0, ty0, tx1, ty1] of carry.tiles) {
|
|
120
|
+
const [x0, y0, x1, y1] = [Math.max(0, tx0 - margin), Math.max(0, ty0 - margin), Math.min(W, tx1 + margin), Math.min(H, ty1 + margin)];
|
|
121
|
+
if (Array.isArray(region)) {
|
|
122
|
+
const [rx0, ry0, rx1, ry1] = region as Rect;
|
|
123
|
+
if (rx0 < x1 && rx1 > x0 && ry0 < y1 && ry1 > y0) return true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const m = region as Mask;
|
|
127
|
+
for (let y = y0; y < y1; y++) {
|
|
128
|
+
for (let x = x0, i = y * m.width + x0; x < x1; x++, i++) if (m.bits[i]) return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A thing or floor this big (px) is masked again only when the change covers a good part of it. */
|
|
135
|
+
const BIG = 20_000;
|
|
136
|
+
const GRAZE = 0.3;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether a repaint changes a mask's outline: it lies on the changed tiles (grown by 8 px), and it is small or a good
|
|
140
|
+
* part of it changed. A thermos repainted on a jetty changes the thermos, not where the jetty is.
|
|
141
|
+
*/
|
|
142
|
+
export function remakes(carry: Carry, mask: Mask): boolean {
|
|
143
|
+
let total = 0;
|
|
144
|
+
for (let i = 0; i < mask.bits.length; i++) total += mask.bits[i];
|
|
145
|
+
let on = 0;
|
|
146
|
+
const seen = new Uint8Array(mask.bits.length);
|
|
147
|
+
for (const [tx0, ty0, tx1, ty1] of carry.tiles) {
|
|
148
|
+
for (let y = Math.max(0, ty0 - 8); y < Math.min(mask.height, ty1 + 8); y++) {
|
|
149
|
+
for (let x = Math.max(0, tx0 - 8), i = y * mask.width + x; x < Math.min(mask.width, tx1 + 8); x++, i++) {
|
|
150
|
+
if (mask.bits[i] && !seen[i]) (seen[i] = 1, on++);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return on > 0 && (total < BIG || on / total > GRAZE);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The painting to ask a question about a region with: the old one when the change does not reach the region. */
|
|
158
|
+
export function paintingFor(carry: Carry | undefined, painting: Buffer, region: Parameters<typeof reaches>[1], margin?: number): Buffer {
|
|
159
|
+
return carry && !reaches(carry, region, margin) ? carry.old : painting;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `made` (built from the old painting) with the changed tiles taken from the new painting, except inside `keep`. */
|
|
163
|
+
export async function withChange(made: Buffer, painting: Buffer, carry: Carry, keep?: Mask): Promise<Buffer> {
|
|
164
|
+
const [out, now] = await Promise.all([rgb(made), rgb(painting)]);
|
|
165
|
+
for (const [x0, y0, x1, y1] of carry.tiles) {
|
|
166
|
+
for (let y = y0; y < y1; y++) {
|
|
167
|
+
if (!keep) {
|
|
168
|
+
now.copy(out, (y * W + x0) * 3, (y * W + x0) * 3, (y * W + x1) * 3);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (let x = x0, i = y * W + x0; x < x1; x++, i++) if (!keep.bits[i]) now.copy(out, i * 3, i * 3, i * 3 + 3);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return sharp(out, { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const tilesLine = (carry: Carry) => `${Math.round(carry.share * 100)}% of the painting changed (${carry.tiles.length} tiles)`;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The person's fixes to the cast, kept in cast/fixes.json and applied by every rebuild: how a character
|
|
2
|
+
// looks (cast, model, sprites paint from it), which take to keep, and which voice speaks their lines.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
6
|
+
|
|
7
|
+
export const CastFixes = z.record(z.string(), z.object({
|
|
8
|
+
looks: z.string().min(10).optional(),
|
|
9
|
+
/** What they hold, as short SAM phrases ("slice of toast"), kept on the cut-out whatever its size (cast_holds); [] keeps nothing. */
|
|
10
|
+
holds: z.array(z.string().min(2)).max(4).optional(),
|
|
11
|
+
take: z.number().int().min(0).optional(),
|
|
12
|
+
/** The person asked for a 3D model (cast_animate) though the story would not build one: the model step builds it every time. */
|
|
13
|
+
animate: z.boolean().optional(),
|
|
14
|
+
voice: z.object({ voice: z.string().min(1), design: z.string().min(10).optional(), why: z.string().default('chosen by the person'), stability: z.number().min(0).max(1).optional(), style: z.number().min(0).max(1).optional() }).optional(),
|
|
15
|
+
/** Rounds of walk and talk sheets asked for again (sprites_redo): each round paints new takes instead of the cached ones. */
|
|
16
|
+
spriteTake: z.number().int().min(0).optional(),
|
|
17
|
+
/** This character's own painting style over world.json's (style_set with scope cast). */
|
|
18
|
+
style: z.string().min(10).optional(),
|
|
19
|
+
}));
|
|
20
|
+
export type CastFixes = z.infer<typeof CastFixes>;
|
|
21
|
+
|
|
22
|
+
export const castFixesFile = (files: WorldFiles) => files.path('cast', 'fixes.json');
|
|
23
|
+
|
|
24
|
+
export function readCastFixes(files: WorldFiles): CastFixes {
|
|
25
|
+
const file = castFixesFile(files);
|
|
26
|
+
return fs.existsSync(file) ? CastFixes.parse(files.readJson(file)) : {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A character with the person's description in place of the world's. */
|
|
30
|
+
export function fixedLooks<T extends { key: string; looks: string }>(files: WorldFiles, who: T): T & { holds?: string[] } {
|
|
31
|
+
const { looks, holds } = readCastFixes(files)[who.key] ?? {};
|
|
32
|
+
return { ...who, ...(looks && { looks }), ...(holds && { holds }) };
|
|
33
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { STYLE_REFERENCE_PROMPT } from '../world/style-reference.ts';
|
|
2
|
+
// The hero and every NPC as one painted figure each: Muse paints them alone on a plain ground, SAM cuts them out.
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import type { Mask } from '../image/mask.ts';
|
|
5
|
+
import { maskBox, union } from '../image/raster.ts';
|
|
6
|
+
import { bodyShare, groundGaps, heldMask, heldThings } from './held.ts';
|
|
7
|
+
import { companions, silhouette } from './warp.ts';
|
|
8
|
+
import type { Memo } from '../meta/memo.ts';
|
|
9
|
+
import { museEdit, musePaint } from '../meta/muse.ts';
|
|
10
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
11
|
+
import { samFind } from '../meta/sam.ts';
|
|
12
|
+
import type { World } from '../world/schema.ts';
|
|
13
|
+
import { restyleFigurePrompt } from './restyle.ts';
|
|
14
|
+
|
|
15
|
+
export const HERO_PX = 600;
|
|
16
|
+
|
|
17
|
+
/** `holds`: what they hold, as SAM phrases, when the person named it (cast/fixes.json); else read from `looks`. */
|
|
18
|
+
export type Who = { key: string; looks: string; holds?: string[] };
|
|
19
|
+
|
|
20
|
+
export function figurePrompt(world: World, who: Who): string {
|
|
21
|
+
return `A single full-body character for a 2D point-and-click adventure game, standing in a relaxed walking-ready pose, seen from the side in three-quarter view, facing to the right. The whole figure is visible from the top of the head down to both feet, centred, about two thirds of the image tall.
|
|
22
|
+
|
|
23
|
+
Character: ${who.looks}
|
|
24
|
+
Style: ${world.style}
|
|
25
|
+
|
|
26
|
+
Plain flat light grey background, no floor, no shadow, no other people, no props around, no text.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The biggest instance SAM finds of the hero, as an RGBA PNG cropped to the figure and HERO_PX tall. */
|
|
30
|
+
export async function cutHero(painting: Buffer, mask: Mask, held?: Mask): Promise<Buffer> {
|
|
31
|
+
return (await cutOut(painting, mask, held)).figure;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The cut-out, and the share of its height the body takes (`share`, 1 unless something held stands above the head):
|
|
36
|
+
* `held` (scene/held.ts) is what SAM found of the things the description has in their hands, kept whatever its size.
|
|
37
|
+
*/
|
|
38
|
+
export async function cutOut(painting: Buffer, mask: Mask, held?: Mask): Promise<{ figure: Buffer; share: number }> {
|
|
39
|
+
if (!maskBox(mask)) throw new Error('figure: the mask is empty');
|
|
40
|
+
const { data, info } = await sharp(painting).resize(mask.width, mask.height, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
41
|
+
// SAM's person leaves out what they hold (a lantern, a net of fish) and leaves holes where it hangs over them.
|
|
42
|
+
// ... and what floats beside them as part of who they are (a heart, the bubble they are in).
|
|
43
|
+
const body = held ? union(mask, held) : mask;
|
|
44
|
+
const whole = companions(data, silhouette(data, body));
|
|
45
|
+
// Held up in both hands, the thing closes a window of plain ground with the arms and head: it stays see-through.
|
|
46
|
+
if (held) for (const gap of groundGaps(data, whole, body)) gap.bits.forEach((b, i) => b && (whole.bits[i] = 0));
|
|
47
|
+
for (let i = 0; i < whole.bits.length; i++) if (!whole.bits[i]) data[i * info.channels + 3] = 0;
|
|
48
|
+
const [x0, y0, x1, y1] = maskBox(whole)!;
|
|
49
|
+
const figure = await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
|
|
50
|
+
.extract({ left: x0, top: y0, width: x1 - x0, height: y1 - y0 })
|
|
51
|
+
.resize({ height: HERO_PX })
|
|
52
|
+
.png()
|
|
53
|
+
.toBuffer();
|
|
54
|
+
return { figure, share: bodyShare(mask, whole) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** For a photo of a real person standing whole in frame: same person, same pose and outline, so live footage can dissolve into the figure. */
|
|
58
|
+
export function photoPrompt(world: World): string {
|
|
59
|
+
return `Repaint this photo of a person as a character of a 2D point-and-click adventure game. Keep the same person, face, hair, clothes, body proportions and exact pose, at the same place and size in the frame, so the outline matches the photo. Show the whole figure from head to feet.
|
|
60
|
+
|
|
61
|
+
Style: ${world.style}
|
|
62
|
+
|
|
63
|
+
Plain flat light grey background, no floor, no shadow, no other people, no text.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* For a photo of a person sitting, lying or cut off by the frame: the same person, standing. Their pose is not
|
|
68
|
+
* theirs to keep: a hero painted sitting against a headboard walks as a sitting cut-out, and the 3D model is
|
|
69
|
+
* built from that painting, headboard and all.
|
|
70
|
+
*/
|
|
71
|
+
export function photoStandPrompt(world: World, who: Who): string {
|
|
72
|
+
return `Paint the person in this photo as a single full-body character for a 2D point-and-click adventure game: the same person, so anyone who knows them sees it at once (same face, hair, beard, build and clothes), but standing upright on both feet in a relaxed walking-ready pose, seen from the side in three-quarter view, facing to the right. In the photo they may be sitting, lying or cut off by the frame; here the whole figure is visible from the top of the head down to both feet, centred, about two thirds of the image tall. What the photo does not show (legs, trousers, shoes) is as described: ${who.looks}
|
|
73
|
+
|
|
74
|
+
Style: ${world.style}
|
|
75
|
+
|
|
76
|
+
Nothing they sat or lay on: no bed, headboard, pillow, chair or blanket. Plain flat light grey background, no floor, no shadow, no other people, no props around, no text.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** For a child's drawing: the character they drew, as they drew it, made whole enough to walk. */
|
|
80
|
+
export function drawingPrompt(world: World, who: Who): string {
|
|
81
|
+
return `This is a child's drawing on paper. Paint the character in it as a single full-body character for a 2D point-and-click adventure game, so the child who drew it knows it at once: the same colours, the same clothes and features, the same shapes and proportions (a big head stays big), only cleaner and whole: every part coloured in solidly, with clear outlines and no white paper showing through, a finished character, not a sketch. Standing in a relaxed walking-ready pose, seen from the side in three-quarter view, facing to the right, the whole figure visible from the top of the head down to both feet (or paws), centred, about two thirds of the image tall.
|
|
82
|
+
|
|
83
|
+
Character: ${who.looks}
|
|
84
|
+
Style: ${world.style}
|
|
85
|
+
|
|
86
|
+
Plain flat light grey background, no paper, no table, no written name, no floor, no shadow, no other characters, no text.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `from` is a photo of a real person (`standing`: whole and on their feet in it, so the pose is kept), or a child's
|
|
91
|
+
* drawing of the character; `restyle` is the character as painted before in another style (`was`), repainted in the
|
|
92
|
+
* world's style with the same face, clothes and pose. `upright` (a person on two legs, cast/figures.json): a figure
|
|
93
|
+
* that comes out sitting, lying or cut off is painted once more, as a seated painting makes a seated walker and a
|
|
94
|
+
* 3D model with the chair grown into it.
|
|
95
|
+
*/
|
|
96
|
+
export async function paintFigure(memo: Memo, world: World, who: Who, take = 0, from?: { photo?: Buffer; standing?: boolean; drawing?: Buffer; concept?: Buffer; styleReference?: Buffer; restyle?: { painting: Buffer; was: string } }, upright = false): Promise<{ painting: Buffer; figure: Buffer; share: number; holds: string[] }> {
|
|
97
|
+
const edit = (args: Parameters<typeof museEdit>[1]) => museEdit(memo, from?.styleReference ? {
|
|
98
|
+
...args, prompt: args.prompt.replace('The LAST image is the creator-approved', 'The second-to-last image is the creator-approved') + '\nThe LAST image: ' + STYLE_REFERENCE_PROMPT,
|
|
99
|
+
images: [...args.images, from.styleReference],
|
|
100
|
+
} : args);
|
|
101
|
+
let fallback: { painting: Buffer; figure: Buffer; share: number; holds: string[]; why: string } | undefined;
|
|
102
|
+
const holds = await heldThings(memo, who);
|
|
103
|
+
// A take SAM finds no one in (a drawing painted back as loose scribble) is painted once more before giving up.
|
|
104
|
+
for (const t of [take, take + 1]) {
|
|
105
|
+
const painting = from?.restyle
|
|
106
|
+
? await edit({ label: `figure:${who.key}:restyle`, prompt: restyleFigurePrompt(who.looks, from.restyle.was, world.style, !!from.drawing), images: [from.restyle.painting, ...(from.drawing ? [from.drawing] : [])], take: t })
|
|
107
|
+
: from?.concept
|
|
108
|
+
? await edit({ label: `figure:${who.key}:approved`, prompt: (from.photo ? from.standing ? photoPrompt(world) : photoStandPrompt(world, who) : from.drawing ? drawingPrompt(world, who) : figurePrompt(world, who)) + '\nThe LAST image is the creator-approved character concept. Keep that identity: face, silhouette, proportions, clothing, markings and colours. Any earlier image is the original source drawing or photo: keep the person or drawn character recognizable. Adapt pose and background only as instructed above. The current written style and any explicit appearance edits take precedence over the old concept.', images: [...(from.photo ? [from.photo] : from.drawing ? [from.drawing] : []), from.concept], take: t })
|
|
109
|
+
: from?.photo
|
|
110
|
+
? from.standing
|
|
111
|
+
? await edit({ label: `figure:${who.key}:photo`, prompt: photoPrompt(world), images: [await sharp(from.photo).rotate().png().toBuffer()], take: t })
|
|
112
|
+
: await edit({ label: `figure:${who.key}:photo-stand`, prompt: photoStandPrompt(world, who), images: [await sharp(from.photo).rotate().png().toBuffer()], take: t })
|
|
113
|
+
: from?.drawing
|
|
114
|
+
? await edit({ label: `figure:${who.key}:drawing`, prompt: drawingPrompt(world, who), images: [from.drawing], take: t })
|
|
115
|
+
: (from?.styleReference ? await edit({ label: `figure:${who.key}`, prompt: figurePrompt(world, who), images: [], take: t }) : await musePaint(memo, { label: `figure:${who.key}`, prompt: figurePrompt(world, who), take: t }));
|
|
116
|
+
const png = await sharp(painting).png().toBuffer();
|
|
117
|
+
// Not every character is a person (a flock of pigeons, a cat): SAM is asked by their own description next.
|
|
118
|
+
const biggest = await findFigure(memo, who, png);
|
|
119
|
+
if (!biggest) continue;
|
|
120
|
+
const made = { painting: png, ...(await cutOut(png, biggest.mask, await heldMask(memo, who.key, png, biggest.mask, holds))), holds };
|
|
121
|
+
const why = (await boxed(made.figure)) ?? (upright ? await notStanding(memo, who.key, made.figure, t) : undefined);
|
|
122
|
+
if (!why) return made;
|
|
123
|
+
console.log(` ${who.key} take ${t}: the figure is not standing whole (${why}); painting it once more`);
|
|
124
|
+
fallback ??= { ...made, why };
|
|
125
|
+
}
|
|
126
|
+
if (fallback) {
|
|
127
|
+
console.log(` ${who.key}: kept a figure that is not standing whole (${fallback.why}); try another take (cast_redo anotherTake)`);
|
|
128
|
+
return { painting: fallback.painting, figure: fallback.figure, share: fallback.share, holds };
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`${who.key}: SAM found no one in the painting, two takes; try another take (cast_redo anotherTake)`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The biggest one SAM finds of the character: as a person, else by its own description (a flock of pigeons, a cat), else as an animal. */
|
|
134
|
+
export async function findFigure(memo: Memo, who: Who, png: Buffer) {
|
|
135
|
+
for (const prompt of ['person', who.looks.split(/[,.;]/)[0].trim().split(/\s+/).slice(0, 6).join(' '), 'animal']) {
|
|
136
|
+
const found = await samFind(memo, { label: `figure:${who.key}`, image: png, prompt });
|
|
137
|
+
if (found.length) return found.reduce((a, b) => (area(b.mask) > area(a.mask) ? b : a));
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Why a cut-out of a person on two legs is not one standing whole, or undefined when it is. A standing figure is
|
|
144
|
+
* about twice as tall as wide (robots and big-headed drawn children down to about 1.4), so only a squatter one is
|
|
145
|
+
* looked at by Spark: sitting with knees up comes out about as wide as tall.
|
|
146
|
+
*/
|
|
147
|
+
export async function notStanding(memo: Memo, key: string, figure: Buffer, take = 0): Promise<string | undefined> {
|
|
148
|
+
const { width = 1, height = 1 } = await sharp(figure).metadata();
|
|
149
|
+
if (height / width >= 1.6) return undefined;
|
|
150
|
+
const flat = await sharp(figure).flatten({ background: '#cccccc' }).png().toBuffer();
|
|
151
|
+
const a = await askSparkJson<{ standing?: unknown; why?: unknown }>(memo, {
|
|
152
|
+
label: `figure-standing:${key}#${take}`, images: [flat],
|
|
153
|
+
prompt: 'This is a character cut out for a 2D adventure game, where it has to walk. Is it standing upright on its own feet, with the whole body from head to feet shown, and nothing it sits or leans on? Sitting, lying, kneeling, crouching, or legs or feet cut off is no. A character that has no legs by nature (a ghost, a blob) is yes.\n\nReply with JSON only: {"standing": true or false, "why": "a few words"}',
|
|
154
|
+
});
|
|
155
|
+
return a.standing === false ? String(a.why ?? 'not standing') || 'not standing' : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const area = (m: Mask) => m.bits.reduce((s, b) => s + b, 0);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Why a cut-out is a box and not a figure, or undefined: one that fills nearly all of its box kept the scene it
|
|
162
|
+
* was painted standing in (a framed picture of stormy sea), and the game would draw that patch around them.
|
|
163
|
+
*/
|
|
164
|
+
export async function boxed(figure: Buffer): Promise<string | undefined> {
|
|
165
|
+
const { data, info } = await sharp(figure).ensureAlpha().extractChannel(3).raw().toBuffer({ resolveWithObject: true });
|
|
166
|
+
let solid = 0;
|
|
167
|
+
for (let i = 0; i < info.width * info.height; i++) if (data[i] > 128) solid++;
|
|
168
|
+
return solid > 0.85 * info.width * info.height ? 'cut out with the scene behind them, a box' : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A grown-up's height, the scale's unit (PERSON_M in src/scene/scale.ts). */
|
|
172
|
+
export const PERSON_M = 1.62;
|
|
173
|
+
|
|
174
|
+
/** How a character stands: metres tall, and whether upright on two legs like a person (a cat is not). */
|
|
175
|
+
export type Body = { height: number; upright: boolean };
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How tall each character stands, in metres (a cat about 0.3, a child about 1.1, a grown-up 1.62), so the
|
|
179
|
+
* runtime draws an animal or a child at its own size, and whether it walks upright like a person: only those
|
|
180
|
+
* get a walking rig, a 3D model and sprite sheets, the rest stay their painted figure. One Spark call for the cast.
|
|
181
|
+
*/
|
|
182
|
+
export async function castBodies(memo: Memo, world: World, cast: Who[]): Promise<Record<string, Body>> {
|
|
183
|
+
if (!cast.length) return {};
|
|
184
|
+
const prompt = `These are the characters of a 2D adventure game (${world.pitch}):
|
|
185
|
+
${cast.map((w) => `- ${w.key}: ${w.looks}`).join('\n')}
|
|
186
|
+
For each one: how tall does it stand, in metres, from the ground to the top of the head (for an animal on four legs, to the top of its head as it stands)? An ordinary grown-up is ${PERSON_M}. And does it stand and walk upright on two legs like a person (a person, a robot, a talking bear in a coat: yes; a cat, a dog, a horse, a bird, a fish: no)?
|
|
187
|
+
Reply with JSON only: {${cast.map((w) => `"${w.key}": {"m": <metres>, "upright": true or false}`).join(', ')}}`;
|
|
188
|
+
const a = await askSparkJson<Record<string, { m?: unknown; upright?: unknown }>>(memo, { label: 'cast-bodies', prompt });
|
|
189
|
+
return Object.fromEntries(cast.flatMap((w) => {
|
|
190
|
+
const m = Number(a[w.key]?.m);
|
|
191
|
+
return Number.isFinite(m) && m > 0 ? [[w.key, { height: Math.round(Math.min(3, Math.max(0.08, m)) * 100) / 100, upright: a[w.key]?.upright !== false }]] : [];
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** cast/figures.json (diorama cast): each character's body; absent, everyone is an upright grown-up. */
|
|
196
|
+
export function readBodies(files: { path(...p: string[]): string; readJson(p: string): unknown }, exists: (p: string) => boolean): Record<string, Body> {
|
|
197
|
+
const at = files.path('cast', 'figures.json');
|
|
198
|
+
return exists(at) ? (files.readJson(at) as Record<string, Body>) : {};
|
|
199
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The person's fixes to a place, kept in places/<place>/corrections.json and applied again by every
|
|
2
|
+
// rebuild: paint repaints their regions, masks takes their names, boxes, kinds, floor cells and
|
|
3
|
+
// brush strokes, derive their stand spots. A fix is never an edit to a generated file, which the
|
|
4
|
+
// next build would overwrite.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { maskArea, type Mask } from '../image/mask.ts';
|
|
9
|
+
import { clampRect, maskBox, type Rect } from '../image/raster.ts';
|
|
10
|
+
import { FRAME } from './layout.ts';
|
|
11
|
+
import { KINDS, type Spots } from './locate.ts';
|
|
12
|
+
|
|
13
|
+
const XY = z.tuple([z.number(), z.number()]);
|
|
14
|
+
export const RectIn = z.tuple([z.number(), z.number(), z.number(), z.number()]).describe('x0, y0, x1, y1 in painting pixels (1200x896)');
|
|
15
|
+
|
|
16
|
+
export const Stroke = z.object({
|
|
17
|
+
mode: z.enum(['add', 'erase']),
|
|
18
|
+
radius: z.number().min(1).max(200),
|
|
19
|
+
points: z.array(XY).min(1).max(2000),
|
|
20
|
+
});
|
|
21
|
+
export type Stroke = z.infer<typeof Stroke>;
|
|
22
|
+
|
|
23
|
+
export const Corrections = z.object({
|
|
24
|
+
/** Persistent instructions for repairing an unfinished layout. */
|
|
25
|
+
layout: z.object({
|
|
26
|
+
guidance: z.string().min(10).max(4000),
|
|
27
|
+
/** layout_fix rounds after a composition rejection since the last new painting take. */
|
|
28
|
+
replans: z.number().int().min(0).optional(),
|
|
29
|
+
}).optional(),
|
|
30
|
+
things: z.record(z.string(), z.object({
|
|
31
|
+
/** What to ask SAM for instead of Spark's phrase. */
|
|
32
|
+
phrase: z.string().min(2).optional(),
|
|
33
|
+
/** Where it really is, instead of Spark's box. */
|
|
34
|
+
box: RectIn.optional(),
|
|
35
|
+
/** How it meets the floor, instead of Spark's reading. */
|
|
36
|
+
kind: z.enum(KINDS).optional(),
|
|
37
|
+
strokes: z.array(Stroke).default([]),
|
|
38
|
+
/** Where the hero stands to use it. */
|
|
39
|
+
stand: XY.optional(),
|
|
40
|
+
/** Where an NPC stands (its feet), instead of the layout's spot. */
|
|
41
|
+
at: XY.optional(),
|
|
42
|
+
/** Not a thing of its own any more (a prop the person took off the list): it stays painted, or is painted out. */
|
|
43
|
+
removed: z.boolean().optional(),
|
|
44
|
+
})).default({}),
|
|
45
|
+
/** Props the person added to the place's things, by key: what it is, in words (it must be in the painting). */
|
|
46
|
+
added: z.record(z.string(), z.object({ looks: z.string().min(3) })).default({}),
|
|
47
|
+
floors: z.record(z.string(), z.object({
|
|
48
|
+
add: z.array(z.string()).default([]),
|
|
49
|
+
remove: z.array(z.string()).default([]),
|
|
50
|
+
strokes: z.array(Stroke).default([]),
|
|
51
|
+
})).default({}),
|
|
52
|
+
repaint: z.array(z.object({ region: RectIn, fix: z.string().min(3) })).default([]),
|
|
53
|
+
/**
|
|
54
|
+
* How the place is worded for the painter, instead of world.json and Spark's brief: set by paint_words, or by
|
|
55
|
+
* paint itself when the image safety filter refused the painting and Spark reworded it.
|
|
56
|
+
*/
|
|
57
|
+
words: z.object({
|
|
58
|
+
shows: z.string().min(10).optional(),
|
|
59
|
+
mood: z.string().min(2).optional(),
|
|
60
|
+
/** One sentence per thing or floor key, used as its look as it is (Spark does not expand it again). */
|
|
61
|
+
looks: z.record(z.string(), z.string().min(10)).default({}),
|
|
62
|
+
by: z.enum(['filter', 'person']).default('person'),
|
|
63
|
+
/** Spark's rewords so far: each asks anew, so a rebuild after refused rewords does not get the same words back. */
|
|
64
|
+
tries: z.number().int().min(0).optional(),
|
|
65
|
+
}).optional(),
|
|
66
|
+
/** Which take of the painting (paint --take): the build asks for a new one when the painting left the layout (FLOOR_LOST). */
|
|
67
|
+
take: z.number().int().min(0).optional(),
|
|
68
|
+
/** Where the hero begins in this place (hero_start_set), instead of the front middle of the biggest floor. */
|
|
69
|
+
start: XY.optional(),
|
|
70
|
+
/** This place's own painting style over world.json's (style_set with scope place). */
|
|
71
|
+
style: z.string().min(10).optional(),
|
|
72
|
+
});
|
|
73
|
+
export type Corrections = z.infer<typeof Corrections>;
|
|
74
|
+
|
|
75
|
+
export const correctionsFile = (placeDir: string) => path.join(placeDir, 'corrections.json');
|
|
76
|
+
|
|
77
|
+
export function readCorrections(placeDir: string): Corrections {
|
|
78
|
+
const file = correctionsFile(placeDir);
|
|
79
|
+
if (!fs.existsSync(file)) return Corrections.parse({});
|
|
80
|
+
return Corrections.parse(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function writeCorrections(placeDir: string, c: Corrections) {
|
|
84
|
+
fs.writeFileSync(correctionsFile(placeDir), `${JSON.stringify(c, null, 2)}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Spark's spots with the person's names, boxes and kinds in their place. */
|
|
88
|
+
export function correctSpots(spots: Spots, c: Corrections): Spots {
|
|
89
|
+
const out: Spots = { ...spots };
|
|
90
|
+
for (const [key, fix] of Object.entries(c.things)) {
|
|
91
|
+
const spot = out[key];
|
|
92
|
+
if (!spot) continue;
|
|
93
|
+
out[key] = {
|
|
94
|
+
...spot,
|
|
95
|
+
...(fix.phrase && { phrase: fix.phrase, also: undefined }),
|
|
96
|
+
...(fix.box && { box: clampRect(fix.box, FRAME.width, FRAME.height) }),
|
|
97
|
+
...(fix.kind && { kind: fix.kind }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Spark's floor cells with the person's added and removed. */
|
|
104
|
+
export function correctCells(marked: Record<string, string[]>, c: Corrections): Record<string, string[]> {
|
|
105
|
+
return Object.fromEntries(Object.entries(marked).map(([key, cells]) => {
|
|
106
|
+
const fix = c.floors[key];
|
|
107
|
+
if (!fix) return [key, cells];
|
|
108
|
+
const removed = new Set(fix.remove.map((s) => s.toUpperCase()));
|
|
109
|
+
return [key, [...new Set([...cells, ...fix.add.map((s) => s.toUpperCase())])].filter((s) => !removed.has(s))];
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Paints brush strokes into a mask: round dabs along each line, added or erased. */
|
|
114
|
+
export function brush(mask: Mask, strokes: Stroke[]): Mask {
|
|
115
|
+
if (strokes.length === 0) return mask;
|
|
116
|
+
const out: Mask = { ...mask, bits: mask.bits.slice() };
|
|
117
|
+
const { width: W, height: H } = out;
|
|
118
|
+
for (const s of strokes) {
|
|
119
|
+
const v = s.mode === 'add' ? 1 : 0;
|
|
120
|
+
const r = s.radius;
|
|
121
|
+
const dab = (cx: number, cy: number) => {
|
|
122
|
+
for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(H - 1, Math.ceil(cy + r)); y++) {
|
|
123
|
+
for (let x = Math.max(0, Math.floor(cx - r)); x <= Math.min(W - 1, Math.ceil(cx + r)); x++) {
|
|
124
|
+
if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) out.bits[y * W + x] = v;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
s.points.forEach(([x, y], i) => {
|
|
129
|
+
const [px, py] = i ? s.points[i - 1] : [x, y];
|
|
130
|
+
const n = Math.max(1, Math.ceil(Math.hypot(x - px, y - py) / Math.max(1, r / 2)));
|
|
131
|
+
for (let k = 1; k <= n; k++) dab(px + ((x - px) * k) / n, py + ((y - py) * k) / n);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A mask after the person's strokes, with its size and box measured again. */
|
|
138
|
+
export function brushed<T extends { mask: Mask; px: number }>(m: T, strokes: Stroke[] | undefined): T & { brushed?: true } {
|
|
139
|
+
if (!strokes?.length) return m;
|
|
140
|
+
const mask = brush(m.mask, strokes);
|
|
141
|
+
const box = maskBox(mask) ?? ([0, 0, 0, 0] as Rect);
|
|
142
|
+
return { ...m, mask, px: maskArea(mask), ...('box' in m && { box }), brushed: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const isEmpty = (c: Corrections) => Object.keys(c.things).length === 0 && Object.keys(c.floors).length === 0 && c.repaint.length === 0;
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The layout with each moved NPC standing where the person put it: feet at the new spot, and as much bigger or
|
|
150
|
+
* smaller as the depth there asks (height grows with the distance below the horizon).
|
|
151
|
+
*/
|
|
152
|
+
export function placeNpcs<L extends { horizon: number; things: Array<{ key: string; x: number; footY: number; width: number; height: number }> }>(layout: L, npcs: ReadonlySet<string>, c: Corrections): L {
|
|
153
|
+
return {
|
|
154
|
+
...layout,
|
|
155
|
+
things: layout.things.map((t) => {
|
|
156
|
+
const at = npcs.has(t.key) ? c.things[t.key]?.at : undefined;
|
|
157
|
+
if (!at) return t;
|
|
158
|
+
const before = t.footY - layout.horizon;
|
|
159
|
+
const after = at[1] - layout.horizon;
|
|
160
|
+
const k = before > 0 && after > 0 ? after / before : 1;
|
|
161
|
+
return { ...t, x: at[0], footY: at[1], width: t.width * k, height: t.height * k };
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
}
|