@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,822 @@
|
|
|
1
|
+
// layers: the backdrop above the horizon (the far bank, hills, a castle) as flats one behind another, like the
|
|
2
|
+
// painted flats of a stage, so they slide at different speeds too. Spark names the bands far to near, SAM cuts
|
|
3
|
+
// each from the backdrop, and each flat grows a few px of its own pixels under the nearer ones, so a nearer
|
|
4
|
+
// flat that slides aside shows the farther one, not a hole. What no flat claims is the sky, filled in where
|
|
5
|
+
// the flats were.
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { maskArea, maskToPng, emptyMask, type Mask } from '../image/mask.ts';
|
|
9
|
+
import { components, dilate, intersect, polygonMask, subtract, union, unionAll } from '../image/raster.ts';
|
|
10
|
+
import type { Memo } from '../meta/memo.ts';
|
|
11
|
+
import { samFind } from '../meta/sam.ts';
|
|
12
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
13
|
+
import type { Place } from '../world/schema.ts';
|
|
14
|
+
import { smearFill } from './backdrop.ts';
|
|
15
|
+
import { FRAME } from './layout.ts';
|
|
16
|
+
import { cellsMask } from './locate.ts';
|
|
17
|
+
|
|
18
|
+
/** How far a flat grows under the nearer ones: more than two neighbouring flats ever slide apart. */
|
|
19
|
+
export const FLAT_GROW = 24;
|
|
20
|
+
/** A flat whose outline is soft (no colour step across it) along more than this share is a cut, not a silhouette. */
|
|
21
|
+
export const CUT_SHARE = 0.45;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The share of a mask's outline (inside the frame, above the horizon line) where the paint on both sides is
|
|
25
|
+
* alike: a silhouette against sky or fog steps in colour, a cut through one building does not. Measured 3 px
|
|
26
|
+
* in and 3 px out across the outline, every other outline pixel.
|
|
27
|
+
*/
|
|
28
|
+
export function cutShare(rgb: Uint8Array, W: number, H: number, m: Mask, above: Mask): number {
|
|
29
|
+
const D = 3;
|
|
30
|
+
let soft = 0;
|
|
31
|
+
let all = 0;
|
|
32
|
+
for (let y = D; y < H - D; y++) {
|
|
33
|
+
for (let x = D; x < W - D; x++) {
|
|
34
|
+
const i = y * W + x;
|
|
35
|
+
if (!m.bits[i] || !above.bits[i] || (x + y) % 2) continue;
|
|
36
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
37
|
+
if (m.bits[i + dy * W + dx]) continue;
|
|
38
|
+
const a = (y - dy * D) * W + (x - dx * D);
|
|
39
|
+
const b = (y + dy * D) * W + (x + dx * D);
|
|
40
|
+
if (!above.bits[b]) break;
|
|
41
|
+
const step = Math.abs(rgb[a * 3] - rgb[b * 3]) + Math.abs(rgb[a * 3 + 1] - rgb[b * 3 + 1]) + Math.abs(rgb[a * 3 + 2] - rgb[b * 3 + 2]);
|
|
42
|
+
all++;
|
|
43
|
+
if (step < 24) soft++;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return all ? soft / all : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Flats smaller than this are not worth a layer. */
|
|
52
|
+
const MIN_FLAT_PX = 2000;
|
|
53
|
+
|
|
54
|
+
const Bands = z.object({ bands: z.array(z.object({ phrase: z.string().min(2), what: z.string().optional() })).max(5) });
|
|
55
|
+
|
|
56
|
+
export type Flat = { phrase: string; png: Buffer; px: number; box: [number, number, number, number] };
|
|
57
|
+
export type Flats = { sky: Buffer; flats: Flat[] };
|
|
58
|
+
|
|
59
|
+
/** The flats above the horizon (farthest first) and the sky behind them; none when the picture has no far view. */
|
|
60
|
+
export async function farFlats(memo: Memo, place: Place, backdrop: Buffer, horizonY: number): Promise<Flats | undefined> {
|
|
61
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
62
|
+
const prompt = `This is a painted backdrop of a game scene. The horizon is at y=${Math.round(horizonY)} of ${H} px.
|
|
63
|
+
List the separate bands of scenery above and around the horizon, from the FARTHEST to the NEAREST, the way a stage has painted flats one behind another: for example distant mountains, then hills, then a town or a castle on a hill, then a row of houses or trees across the water.
|
|
64
|
+
Do not list the sky, clouds, the sun, water, the ground the characters walk on, or anything in the foreground.
|
|
65
|
+
Inside a room (a bedroom, a kitchen, a hall, a cellar) its own walls, ceiling, doors and windows are not far scenery: list no bands for them. List bands there only for a far view seen through a big opening (a wide window onto a city, an open gate onto hills).
|
|
66
|
+
List no bands at all when there is no far view: {"bands": []}.
|
|
67
|
+
List up to 4 bands, each one a different distance. Give each a short noun phrase that an image segmentation model can search for ("distant mountains", "row of townhouses", "castle on a hill").
|
|
68
|
+
Answer only with JSON: {"bands": [{"phrase": "...", "what": "one short sentence"}]}`;
|
|
69
|
+
const { bands } = Bands.parse(await askSparkJson(memo, { schema: Bands, label: `flats:${place.key}`, prompt, images: [backdrop] }));
|
|
70
|
+
if (!bands.length) return undefined;
|
|
71
|
+
// Only what stands above (or just at) the horizon: below it the floor and the upright band move already.
|
|
72
|
+
const above = emptyMask(W, H);
|
|
73
|
+
above.bits.fill(1, 0, Math.min(H, Math.round(horizonY) + 6) * W);
|
|
74
|
+
const found = await Promise.all(bands.map(async (b, i) => {
|
|
75
|
+
// SAM often misses a long phrase ("castle on forested hill") and knows its head noun ("castle").
|
|
76
|
+
let hits = await samFind(memo, { label: `flat:${place.key}/${i}`, image: backdrop, prompt: b.phrase });
|
|
77
|
+
const head = headNoun(b.phrase);
|
|
78
|
+
if (!hits.length && head !== b.phrase) hits = await samFind(memo, { label: `flat:${place.key}/${i}~${head}`, image: backdrop, prompt: head });
|
|
79
|
+
return intersect(unionAll(W, H, hits.map((f) => f.mask)), above);
|
|
80
|
+
}));
|
|
81
|
+
// Each pixel belongs to the nearest flat that claims it.
|
|
82
|
+
const owned: Mask[] = [];
|
|
83
|
+
let taken = emptyMask(W, H);
|
|
84
|
+
for (let i = found.length - 1; i >= 0; i--) {
|
|
85
|
+
owned[i] = subtract(found[i], taken);
|
|
86
|
+
taken = unionAll(W, H, [taken, owned[i]]);
|
|
87
|
+
}
|
|
88
|
+
let keep = owned.map((m, i) => ({ m, i, px: maskArea(m), box: boxOf(m) })).filter((f) => f.px >= MIN_FLAT_PX);
|
|
89
|
+
// A small flat mostly inside a bigger, farther one (a castle on its hill) stays on it: it would slide off.
|
|
90
|
+
for (let k = keep.length - 1; k > 0; k--) {
|
|
91
|
+
const f = keep[k];
|
|
92
|
+
const host = keep.slice(0, k).find((b) => b.px > f.px && inside(f.box, b.box) >= 0.4);
|
|
93
|
+
if (!host) continue;
|
|
94
|
+
host.m = unionAll(W, H, [host.m, f.m]);
|
|
95
|
+
host.px += f.px;
|
|
96
|
+
host.box = boxOf(host.m);
|
|
97
|
+
keep = keep.filter((x) => x !== f);
|
|
98
|
+
}
|
|
99
|
+
if (!keep.length) return undefined;
|
|
100
|
+
const rgb = new Uint8Array(await sharp(backdrop).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
|
|
101
|
+
// A flat whose outline mostly runs through paint of the same colour cut a building or a stall in two (SAM
|
|
102
|
+
// took half a pavilion with the row of houses): sliding, the two halves would part. Such a flat stays painted.
|
|
103
|
+
keep = keep.filter((f) => cutShare(rgb, W, H, f.m, above) <= CUT_SHARE);
|
|
104
|
+
if (!keep.length) return undefined;
|
|
105
|
+
const all = unionAll(W, H, keep.map((f) => f.m));
|
|
106
|
+
const flats: Flat[] = [];
|
|
107
|
+
for (const [k, f] of keep.entries()) {
|
|
108
|
+
// Grow only under nearer flats (and below the horizon line, which the floor covers), never out over the sky.
|
|
109
|
+
const nearer = unionAll(W, H, keep.slice(k + 1).map((n) => n.m));
|
|
110
|
+
const under = unionAll(W, H, [nearer, subtract(emptyMaskAll(W, H), above)]);
|
|
111
|
+
const reach = unionAll(W, H, [f.m, intersect(dilate(f.m, FLAT_GROW), under)]);
|
|
112
|
+
const filled = smearFill(rgb, W, H, subtract(reach, f.m), f.m);
|
|
113
|
+
const alpha = await sharp(await maskToPng(reach)).blur(1).extractChannel(0).raw().toBuffer();
|
|
114
|
+
const rgba = Buffer.alloc(W * H * 4);
|
|
115
|
+
for (let i = 0; i < W * H; i++) {
|
|
116
|
+
rgba[i * 4] = filled[i * 3];
|
|
117
|
+
rgba[i * 4 + 1] = filled[i * 3 + 1];
|
|
118
|
+
rgba[i * 4 + 2] = filled[i * 3 + 2];
|
|
119
|
+
rgba[i * 4 + 3] = alpha[i];
|
|
120
|
+
}
|
|
121
|
+
const [x0, y0, x1, y1] = boxOf(reach);
|
|
122
|
+
const png = await sharp(rgba, { raw: { width: W, height: H, channels: 4 } }).extract({ left: x0, top: y0, width: x1 - x0, height: y1 - y0 }).png().toBuffer();
|
|
123
|
+
flats.push({ phrase: bands[f.i].phrase, png, px: f.px, box: [x0, y0, x1, y1] });
|
|
124
|
+
}
|
|
125
|
+
// The sky: the backdrop with every flat filled in from what is around it, so a flat that slides shows sky.
|
|
126
|
+
const hole = dilate(all, 2);
|
|
127
|
+
const sky = await sharp(smearFill(rgb, W, H, hole, subtract(above, hole)), { raw: { width: W, height: H, channels: 3 } }).png().toBuffer();
|
|
128
|
+
return { sky, flats };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function emptyMaskAll(W: number, H: number): Mask {
|
|
132
|
+
const m = emptyMask(W, H);
|
|
133
|
+
m.bits.fill(1);
|
|
134
|
+
return m;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** "castle on a hill" → "castle", "row of townhouses" → "townhouses", "distant rolling hills" → "hills". */
|
|
138
|
+
export function headNoun(phrase: string): string {
|
|
139
|
+
const words = phrase.trim().toLowerCase().split(/\s+/);
|
|
140
|
+
const at = words.findIndex((w, k) => k > 0 && ['on', 'with', 'in', 'behind', 'above', 'near'].includes(w));
|
|
141
|
+
return at > 0 ? words[at - 1] : words.at(-1)!;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function boxOf(m: Mask): [number, number, number, number] {
|
|
145
|
+
let [x0, y0, x1, y1] = [m.width, m.height, 0, 0];
|
|
146
|
+
for (let i = 0; i < m.bits.length; i++) {
|
|
147
|
+
if (!m.bits[i]) continue;
|
|
148
|
+
const x = i % m.width;
|
|
149
|
+
const y = (i / m.width) | 0;
|
|
150
|
+
[x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x + 1), Math.max(y1, y + 1)];
|
|
151
|
+
}
|
|
152
|
+
return [x0, y0, x1, y1];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The share of box a that lies inside box b. */
|
|
156
|
+
function inside(a: number[], b: number[]): number {
|
|
157
|
+
const w = Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0]));
|
|
158
|
+
const h = Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1]));
|
|
159
|
+
return (w * h) / Math.max(1, (a[2] - a[0]) * (a[3] - a[1]));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** How far the water texture reaches past the water's edge: more than the flow ever moves it in one cycle. */
|
|
163
|
+
export const WATER_PAD = 64;
|
|
164
|
+
|
|
165
|
+
/** The way the water flows on screen, a unit vector (downstream); [0, 0] for still water. */
|
|
166
|
+
export type Flow = [number, number];
|
|
167
|
+
export type Water = { tex: Buffer; mask: Buffer; box: [number, number, number, number]; phrase: string; flow: Flow };
|
|
168
|
+
|
|
169
|
+
const FlowAnswer = z.object({
|
|
170
|
+
flows: z.boolean(),
|
|
171
|
+
from: z.tuple([z.number(), z.number()]).optional(),
|
|
172
|
+
to: z.tuple([z.number(), z.number()]).optional(),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Which way the water runs on screen. A river or canal runs along its banks, so it is a line on the picture,
|
|
177
|
+
* often into the depth (down the frame toward the viewer, or up toward a far bridge), not just sideways.
|
|
178
|
+
* Spark reads it as an upstream and a downstream point; a lake, a harbour basin or the sea stays still.
|
|
179
|
+
*/
|
|
180
|
+
export async function flowOf(memo: Memo, place: Place, backdrop: Buffer, box: [number, number, number, number], hint?: string): Promise<Flow> {
|
|
181
|
+
const prompt = `This is a painted game scene, ${FRAME.width}x${FRAME.height} px. There is ${place.key.replace(/-/g, ' ')} water in the box x ${box[0]}..${box[2]}, y ${box[1]}..${box[3]}.
|
|
182
|
+
Which way does the water run?${hint ? ` The author says: "${hint}". Follow that.` : ''} A river or a canal runs along its banks: follow the channel, which may lead into the depth of the picture (toward a far bridge or arch, or toward the viewer), not only left or right. Pick the more natural direction; when nothing shows it, water runs toward the viewer.
|
|
183
|
+
Answer with JSON only: {"flows": true, "from": [x, y], "to": [x, y]}, two points on the water in px, "from" upstream and "to" downstream, far apart along the channel. For still water (a pond, a lake, a harbour basin, the open sea) answer {"flows": false}.`;
|
|
184
|
+
const a = FlowAnswer.parse(await askSparkJson(memo, { schema: FlowAnswer, label: `water-flow:${place.key}`, prompt, images: [backdrop] }));
|
|
185
|
+
if (!a.flows || !a.from || !a.to) return [0, 0];
|
|
186
|
+
const [dx, dy] = [a.to[0] - a.from[0], a.to[1] - a.from[1]];
|
|
187
|
+
const len = Math.hypot(dx, dy);
|
|
188
|
+
return len < 1 ? [0, 0] : [Math.round((dx / len) * 1000) / 1000, Math.round((dy / len) * 1000) / 1000];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** How the author wants a place's depth: places/<key>/depth.json, set by the water_flow and fog_drift actions, kept across rebuilds. */
|
|
192
|
+
export const Depth = z.object({
|
|
193
|
+
/** auto: as Spark reads it; reverse: the other way round; still: the water does not flow. */
|
|
194
|
+
flow: z.enum(['auto', 'reverse', 'still']).default('auto'),
|
|
195
|
+
/** What the author said about the flow ("toward the bridge"), handed to Spark. */
|
|
196
|
+
flowHint: z.string().optional(),
|
|
197
|
+
/** Which way the painted fog drifts (the fog_drift action): auto with the wind, left, right, or still. */
|
|
198
|
+
fog: z.enum(['auto', 'left', 'right', 'still']).default('auto'),
|
|
199
|
+
/** How fast it drifts, 0.25 (barely) .. 3 (rolling); 1 by default. */
|
|
200
|
+
fogSpeed: z.number().min(0.25).max(3).optional(),
|
|
201
|
+
/** 60 px grid cells (like C12) that are water SAM missed (water_area add), or are not water at all (water_area remove). */
|
|
202
|
+
waterAdd: z.array(z.string()).default([]),
|
|
203
|
+
waterRemove: z.array(z.string()).default([]),
|
|
204
|
+
});
|
|
205
|
+
export type Depth = z.infer<typeof Depth>;
|
|
206
|
+
|
|
207
|
+
/** A floor is taken to have swallowed the water when it holds this share of what SAM found, and is this much water itself. */
|
|
208
|
+
const SWALLOWED_WATER = 0.5;
|
|
209
|
+
const SWALLOWED_FLOOR = 0.3;
|
|
210
|
+
/** An added cell keeps SAM's water edge when SAM saw this much water in it; otherwise the whole cell flows. */
|
|
211
|
+
const ADDED_SEEN = 0.3;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where the water flows, from what SAM found: the cards (standing things) never flow, nor the floors, since
|
|
215
|
+
* SAM's "water" also takes wet planks and shiny cobbles. But a floor that holds most of the water and is mostly
|
|
216
|
+
* water itself (a "far bank" that took in the river in front of it) is wrong about it: the water wins there,
|
|
217
|
+
* or only a strip at the bank would move. Then the author's cells (water_area): added cells flow (along SAM's
|
|
218
|
+
* edge where it saw water there), removed ones never do. `swallowed` names the floors the water won over.
|
|
219
|
+
*/
|
|
220
|
+
export function waterMask(found: Mask, cards: Mask, floors: Array<{ key: string; mask: Mask }>, depth: Pick<Depth, 'waterAdd' | 'waterRemove'>): { mask: Mask; swallowed: string[] } {
|
|
221
|
+
const { width: W, height: H } = found;
|
|
222
|
+
const all = maskArea(found);
|
|
223
|
+
const swallowed: string[] = [];
|
|
224
|
+
const dry = floors.filter(({ key, mask }) => {
|
|
225
|
+
const both = maskArea(intersect(mask, found));
|
|
226
|
+
const takes = all > 0 && both / all >= SWALLOWED_WATER && both / Math.max(1, maskArea(mask)) >= SWALLOWED_FLOOR;
|
|
227
|
+
if (takes) swallowed.push(key);
|
|
228
|
+
return !takes;
|
|
229
|
+
});
|
|
230
|
+
const dryLand = dilate(unionAll(W, H, [...dry.map((f) => f.mask), cards]), 6);
|
|
231
|
+
let mask = subtract(found, dryLand);
|
|
232
|
+
for (const cell of depth.waterAdd) {
|
|
233
|
+
const at = cellsMask([cell]);
|
|
234
|
+
const seen = intersect(found, at);
|
|
235
|
+
// A whole cell on the shore takes in a corner of the quay too: the floors and cards stay dry in it.
|
|
236
|
+
mask = union(mask, maskArea(seen) >= ADDED_SEEN * maskArea(at) ? seen : subtract(at, dryLand));
|
|
237
|
+
}
|
|
238
|
+
if (depth.waterRemove.length) mask = subtract(mask, cellsMask(depth.waterRemove));
|
|
239
|
+
return { mask, swallowed };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The water of a place (a river, a canal, the sea) for the runtime to set flowing: its pixels, carried past
|
|
244
|
+
* its edges from its own colours so a shifted copy never shows the bank, and its mask. None without water.
|
|
245
|
+
* `cards` and `floors` may be wet in SAM's eyes: see waterMask.
|
|
246
|
+
*/
|
|
247
|
+
/** Words that say a place may show water, in English and Polish (world text follows the world's language). */
|
|
248
|
+
const WET = /\b(water|river|sea|ocean|lake|pond|pool|stream|canal|creek|brook|waves?|fountain|puddles?|harbou?r|port|quay|dock|pier|jetty|wharf|beach|shore|coast|bay|marsh|swamp|bridge|boats?|ship|ferry|rain|wet|flood|waterfall|spring|well)|wod|rzek|rzecz|morz|jezior|staw|strum|potok|kana[łl]|fontann|ka[łl]u[żz]|port|nabrze|przysta|pla[żz]|brzeg|most|[łl][óo]d[źz]|[łl]odz|statk|prom|deszcz|mokr|bagn|wodospad|studni|zdr[óo]j/i;
|
|
249
|
+
|
|
250
|
+
export function mayBeWet(place: Place): boolean {
|
|
251
|
+
return WET.test([place.key, place.shows, place.mood, ...place.things.map((t) => JSON.stringify(t))].join(' '));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const WaterOutlines = z.object({ areas: z.array(z.object({ what: z.string().optional(), outline: z.array(z.tuple([z.number(), z.number()])).min(3) })).max(4) });
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Water SAM does not know: a sea of torn blue paper with crumpled white foam, felt waves, a woodcut river. Spark
|
|
258
|
+
* outlines each body of water; the floors, cards and painted things are taken out of it after (see waterMask),
|
|
259
|
+
* so a rough outline that runs over the quay still leaves the quay dry.
|
|
260
|
+
*/
|
|
261
|
+
export async function waterOutlines(memo: Memo, place: Place, backdrop: Buffer): Promise<{ mask: Mask; phrase: string } | undefined> {
|
|
262
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
263
|
+
const prompt = `This is a painted game scene, ${W}x${H} px. Is there water in it (the sea, a river, a lake, a harbour, a pond), painted in any style: torn or folded paper waves with crumpled white foam, felt, cardboard, a woodcut?
|
|
264
|
+
Answer with JSON only: {"areas": [{"what": "stormy sea", "outline": [[x, y], ...]}]}, 8 to 20 points in px around the visible surface of each body of water (its waves and foam included; not the sky, a quay, a beach, rocks, boats or a waterfall), at most 4 areas; or {"areas": []} when there is none.`;
|
|
265
|
+
const { areas } = WaterOutlines.parse(await askSparkJson(memo, { schema: WaterOutlines, label: `water-outline:${place.key}`, prompt, images: [backdrop] }));
|
|
266
|
+
if (!areas.length) return undefined;
|
|
267
|
+
return { mask: unionAll(W, H, areas.map((a) => polygonMask(W, H, a.outline))), phrase: areas.map((a) => a.what ?? 'water').join(' · ') };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function waterSheet(memo: Memo, place: Place, backdrop: Buffer, cards: Mask, floors: Array<{ key: string; mask: Mask }>, depth: Depth = Depth.parse({}), under?: Mask): Promise<Water | undefined> {
|
|
271
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
272
|
+
let mask: Mask | undefined;
|
|
273
|
+
let phrase = '';
|
|
274
|
+
let swallowed: string[] = [];
|
|
275
|
+
// SAM is asked about water only where the place could have some (its description, its things), or the author
|
|
276
|
+
// marked it: a clockmaker's workshop paid three asks, one after another, to hear there is no sea in it.
|
|
277
|
+
// What SAM finds must be water by itself: the author's cells (water_area) alone used to pass for it, and a paper
|
|
278
|
+
// sea SAM never saw was left to them, still and blocky.
|
|
279
|
+
const own = Depth.parse({});
|
|
280
|
+
let found: Mask | undefined;
|
|
281
|
+
for (const p of mayBeWet(place) ? ['water', 'river', 'sea'] : []) {
|
|
282
|
+
const hits = await samFind(memo, { label: `water:${place.key}/${p}`, image: backdrop, prompt: p });
|
|
283
|
+
const all = unionAll(W, H, hits.map((f) => f.mask));
|
|
284
|
+
if (maskArea(waterMask(all, cards, floors, own).mask) >= 8000) [found, phrase] = [all, p];
|
|
285
|
+
if (found) break;
|
|
286
|
+
}
|
|
287
|
+
// SAM saw no water where there may be some: a stylised sea (paper, felt) it does not call water. Spark outlines it.
|
|
288
|
+
if (!found && mayBeWet(place)) {
|
|
289
|
+
const drawn = await waterOutlines(memo, place, backdrop).catch((error) => {
|
|
290
|
+
console.log(` ${place.key}: no water outlined (${error instanceof Error ? error.message.slice(0, 120) : error})`);
|
|
291
|
+
return undefined;
|
|
292
|
+
});
|
|
293
|
+
if (drawn && maskArea(waterMask(drawn.mask, cards, floors, own).mask) >= 8000) {
|
|
294
|
+
[found, phrase] = [drawn.mask, drawn.phrase];
|
|
295
|
+
console.log(` ${place.key}: SAM saw no water; Spark outlined ${drawn.phrase}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Then the author's cells: added where SAM or Spark missed water, taken out where it is none.
|
|
299
|
+
if (found || depth.waterAdd.length) {
|
|
300
|
+
const m = waterMask(found ?? emptyMask(W, H), cards, floors, depth);
|
|
301
|
+
if (maskArea(m.mask) > 0) [mask, swallowed, phrase] = [m.mask, m.swallowed, phrase || 'water'];
|
|
302
|
+
}
|
|
303
|
+
if (mask && swallowed.length) console.log(` ${place.key}: the floor ${swallowed.join(', ')} took in the ${phrase}: it flows there anyway`);
|
|
304
|
+
if (!mask) return undefined;
|
|
305
|
+
const [mx0, my0, mx1, my1] = boxOf(mask);
|
|
306
|
+
// Carried past the edges on all sides: water running into the depth also moves up and down the frame.
|
|
307
|
+
const box: [number, number, number, number] = [Math.max(0, mx0 - WATER_PAD), Math.max(0, my0 - WATER_PAD), Math.min(W, mx1 + WATER_PAD), Math.min(H, my1 + WATER_PAD)];
|
|
308
|
+
const raw = new Uint8Array(await sharp(backdrop).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
|
|
309
|
+
// What swims in the water (fish set swimming as cards of their own) is painted out of it: the water beside them copied over them.
|
|
310
|
+
const swum = under && intersect(dilate(under, 3), dilate(mask, 24));
|
|
311
|
+
const rgb = swum && maskArea(swum) ? cloneFill(raw, W, H, swum) : raw;
|
|
312
|
+
if (swum) mask = union(mask, swum);
|
|
313
|
+
// Everything in the box that is not water gets the water's colours, row by row from the nearest water.
|
|
314
|
+
const reach = emptyMask(W, H);
|
|
315
|
+
for (let y = box[1]; y < box[3]; y++) reach.bits.fill(1, y * W + box[0], y * W + box[2]);
|
|
316
|
+
const filled = smearFill(rgb, W, H, subtract(reach, mask), mask);
|
|
317
|
+
const region = { left: box[0], top: box[1], width: box[2] - box[0], height: box[3] - box[1] };
|
|
318
|
+
const tex = await sharp(filled, { raw: { width: W, height: H, channels: 3 } }).extract(region).jpeg({ quality: 90 }).toBuffer();
|
|
319
|
+
// The mask is shrunk a hair and feathered, so the flowing copy fades into the still painting at the banks.
|
|
320
|
+
// The page cuts with the mask's alpha (destination-in), so it is white with the water as alpha.
|
|
321
|
+
const alpha = await sharp(await maskToPng(dilate(mask, -2))).blur(2).extract(region).extractChannel(0).toBuffer();
|
|
322
|
+
const soft = await sharp({ create: { width: region.width, height: region.height, channels: 3, background: '#ffffff' } }).joinChannel(alpha).png().toBuffer();
|
|
323
|
+
const read = depth.flow === 'still' ? ([0, 0] as Flow) : await flowOf(memo, place, backdrop, [mx0, my0, mx1, my1], depth.flowHint).catch((error) => {
|
|
324
|
+
console.log(` ${place.key}: flow direction unknown, left to right (${error instanceof Error ? error.message.slice(0, 120) : error})`);
|
|
325
|
+
return [1, 0] as Flow;
|
|
326
|
+
});
|
|
327
|
+
const flow: Flow = depth.flow === 'reverse' ? [-read[0] || 0, -read[1] || 0] : read;
|
|
328
|
+
return { tex, mask: soft, box, phrase, flow };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** How far the mist texture reaches past the fog's edge: more than it ever drifts in one cycle. */
|
|
332
|
+
export const MIST_PAD = 160;
|
|
333
|
+
/** Painted fog drifts only as a band: a frame-wide haze is the weather's mist (weather_set fog), not a layer. */
|
|
334
|
+
const MIST_MAX_SHARE = 0.55;
|
|
335
|
+
|
|
336
|
+
/** Painted fog for the runtime to set drifting: a veil of its colour shaped like it (reaching past its edges), and where it may show. */
|
|
337
|
+
export type Mist = { tex: Buffer; mask: Buffer; box: [number, number, number, number]; phrase: string; drift: -1 | 0 | 1 | null; speed: number };
|
|
338
|
+
|
|
339
|
+
const FogBands = z.object({ bands: z.array(z.object({ box: z.tuple([z.number(), z.number(), z.number(), z.number()]), what: z.string().optional() })).max(4) });
|
|
340
|
+
|
|
341
|
+
/** Soft wide streaks 0.35..1 over w x h, the same for the same place: smooth value noise, three octaves, stretched sideways. */
|
|
342
|
+
function wisps(key: string, w: number, h: number): Float32Array {
|
|
343
|
+
let seed = [...key].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
|
|
344
|
+
const rand = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2 ** 32);
|
|
345
|
+
const out = new Float32Array(w * h).fill(0);
|
|
346
|
+
const smooth = (t: number) => t * t * (3 - 2 * t);
|
|
347
|
+
let total = 0;
|
|
348
|
+
for (const [cx, cy, amp] of [[280, 70, 1], [140, 36, 0.5], [70, 18, 0.25]]) {
|
|
349
|
+
const [gw, gh] = [Math.ceil(w / cx) + 2, Math.ceil(h / cy) + 2];
|
|
350
|
+
const grid = Float32Array.from({ length: gw * gh }, rand);
|
|
351
|
+
for (let y = 0; y < h; y++) {
|
|
352
|
+
const gy = y / cy;
|
|
353
|
+
const [j, ty] = [Math.floor(gy), smooth(gy - Math.floor(gy))];
|
|
354
|
+
for (let x = 0; x < w; x++) {
|
|
355
|
+
const gx = x / cx;
|
|
356
|
+
const [i, tx] = [Math.floor(gx), smooth(gx - Math.floor(gx))];
|
|
357
|
+
const a = grid[j * gw + i] + (grid[j * gw + i + 1] - grid[j * gw + i]) * tx;
|
|
358
|
+
const b = grid[(j + 1) * gw + i] + (grid[(j + 1) * gw + i + 1] - grid[(j + 1) * gw + i]) * tx;
|
|
359
|
+
out[y * w + x] += amp * (a + (b - a) * ty);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
total += amp;
|
|
363
|
+
}
|
|
364
|
+
for (let k = 0; k < out.length; k++) out[k] = 0.35 + 0.65 * Math.min(1, Math.max(0, (out[k] / total - 0.3) / 0.45));
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* How foggy each pixel of a box is, 0..255: fog is pale and greyed, lighter than the box's own darker half. A
|
|
370
|
+
* lit window or a white wall is pale too, but small: the blur after it keeps only wide soft veils.
|
|
371
|
+
*/
|
|
372
|
+
async function fogness(rgb: Uint8Array, W: number, H: number, [x0, y0, x1, y1]: number[]): Promise<Uint8Array> {
|
|
373
|
+
const lumas: number[] = [];
|
|
374
|
+
const out = new Uint8Array(W * H);
|
|
375
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x += 3) {
|
|
376
|
+
const i = (y * W + x) * 3;
|
|
377
|
+
lumas.push(0.3 * rgb[i] + 0.59 * rgb[i + 1] + 0.11 * rgb[i + 2]);
|
|
378
|
+
}
|
|
379
|
+
lumas.sort((a, b) => a - b);
|
|
380
|
+
const lo = lumas[Math.floor(lumas.length * 0.35)] ?? 0;
|
|
381
|
+
const hi = Math.max(lo + 12, lumas[Math.floor(lumas.length * 0.95)] ?? 255);
|
|
382
|
+
const [bw, bh] = [x1 - x0, y1 - y0];
|
|
383
|
+
const f = new Float32Array(bw * bh);
|
|
384
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
385
|
+
const i = (y * W + x) * 3;
|
|
386
|
+
const [r, g, b] = [rgb[i], rgb[i + 1], rgb[i + 2]];
|
|
387
|
+
const max = Math.max(r, g, b);
|
|
388
|
+
const sat = max ? (max - Math.min(r, g, b)) / max : 0;
|
|
389
|
+
const light = Math.min(1, Math.max(0, (0.3 * r + 0.59 * g + 0.11 * b - lo) / (hi - lo)));
|
|
390
|
+
const grey = Math.min(1, Math.max(0, (0.6 - sat) / 0.35));
|
|
391
|
+
// The box's rim fades out, so a band has no straight edge.
|
|
392
|
+
const rim = Math.min(1, Math.min(x - x0, x1 - 1 - x) / (0.15 * bw), Math.min(y - y0, y1 - 1 - y) / (0.2 * bh));
|
|
393
|
+
f[(y - y0) * bw + x - x0] = light * grey * rim;
|
|
394
|
+
}
|
|
395
|
+
// As foggy as the box's foggiest tenth: a night fog is blue and dim, yet it is the fog of that picture.
|
|
396
|
+
const top = Math.max(0.15, [...f].sort((a, b) => a - b)[Math.floor(f.length * 0.9)]);
|
|
397
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) out[y * W + x] = Math.round(255 * Math.min(1, f[(y - y0) * bw + x - x0] / top));
|
|
398
|
+
return new Uint8Array(await sharp(out, { raw: { width: W, height: H, channels: 1 } }).blur(8).extractChannel(0).raw().toBuffer());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The fog or mist painted into a place (a bank over the land, haze round a far lighthouse), for the page to
|
|
403
|
+
* drift: the painting holds it still, so a veil of the fog's own colour, shaped like its denser and lighter
|
|
404
|
+
* patches, slides over it, and the wisps roll along. Only colour and density move, so the land under the fog
|
|
405
|
+
* never slides or blurs. SAM cuts the fog when it knows it; it often does not (fog has no outline), so Spark
|
|
406
|
+
* then names the boxes where fog bands lie, and the fog in them is read from the pixels. None without fog, or
|
|
407
|
+
* when the whole picture is haze (that is the weather's mist). `notMist`: the standing things, which cover it
|
|
408
|
+
* anyway; `floors`: where people walk, where the veil is thin, so they never wade in milk.
|
|
409
|
+
*/
|
|
410
|
+
export async function mistSheet(memo: Memo, place: Place, backdrop: Buffer, notMist: Mask, floors?: Mask, depth: Depth = Depth.parse({})): Promise<Mist | undefined> {
|
|
411
|
+
if (depth.fog === 'still') return undefined;
|
|
412
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
413
|
+
const fits = (px: number) => px >= 8000 && px <= MIST_MAX_SHARE * W * H;
|
|
414
|
+
const rgb = new Uint8Array(await sharp(backdrop).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
|
|
415
|
+
const density = new Uint8Array(W * H);
|
|
416
|
+
let phrase = '';
|
|
417
|
+
// Asked first, once: is there fog at all? Most places have none, and SAM was asked fog, mist and haze one after
|
|
418
|
+
// another on every one of them (three calls and up to three minutes in the queue) before Spark said "clear".
|
|
419
|
+
const fogPrompt = `This is a painted game scene, ${W}x${H} px. Is there fog, mist or haze painted in it: a bank of fog over the land or the water, mist round a far building, a veil over hills?
|
|
420
|
+
Answer with JSON only: {"bands": [{"box": [x0, y0, x1, y1], "what": "low fog over the dunes"}]}, one box in px round each band of fog (at most 4), or {"bands": []} when the air is clear. Not clouds high in the sky, not smoke from a chimney.`;
|
|
421
|
+
const { bands } = FogBands.parse(await askSparkJson(memo, { schema: FogBands, label: `mist-bands:${place.key}`, prompt: fogPrompt, images: [backdrop] }));
|
|
422
|
+
if (!bands.length) return undefined;
|
|
423
|
+
for (const p of ['fog', 'mist', 'haze']) {
|
|
424
|
+
const hits = await samFind(memo, { label: `mist:${place.key}/${p}`, image: backdrop, prompt: p });
|
|
425
|
+
const m = subtract(unionAll(W, H, hits.map((f) => f.mask)), notMist);
|
|
426
|
+
if (!fits(maskArea(m))) continue;
|
|
427
|
+
const soft = new Uint8Array(await sharp(dilate(m, -6).bits.map((b) => b * 255), { raw: { width: W, height: H, channels: 1 } }).blur(14).extractChannel(0).raw().toBuffer());
|
|
428
|
+
const f = await fogness(rgb, W, H, boxOf(m));
|
|
429
|
+
for (let i = 0; i < density.length; i++) density[i] = (f[i] * soft[i]) / 255;
|
|
430
|
+
phrase = p;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
if (!phrase) {
|
|
434
|
+
for (const b of bands) {
|
|
435
|
+
const [x0, y0, x1, y1] = [Math.max(0, Math.round(b.box[0])), Math.max(0, Math.round(b.box[1])), Math.min(W, Math.round(b.box[2])), Math.min(H, Math.round(b.box[3]))];
|
|
436
|
+
if (x1 - x0 < 40 || y1 - y0 < 20) continue;
|
|
437
|
+
const f = await fogness(rgb, W, H, [x0, y0, x1, y1]);
|
|
438
|
+
for (let i = 0; i < f.length; i++) if (f[i] > density[i]) density[i] = f[i];
|
|
439
|
+
}
|
|
440
|
+
phrase = bands.map((b) => b.what ?? 'fog').join(' · ');
|
|
441
|
+
}
|
|
442
|
+
for (let i = 0; i < density.length; i++) {
|
|
443
|
+
if (notMist.bits[i]) density[i] = 0;
|
|
444
|
+
else if (floors?.bits[i]) density[i] = Math.round(density[i] * 0.35);
|
|
445
|
+
}
|
|
446
|
+
const mask = emptyMask(W, H);
|
|
447
|
+
for (let i = 0; i < density.length; i++) mask.bits[i] = density[i] > 40 ? 1 : 0;
|
|
448
|
+
if (!phrase || !fits(maskArea(mask))) return undefined;
|
|
449
|
+
const [mx0, my0, mx1, my1] = boxOf(mask);
|
|
450
|
+
// Fog drifts sideways: the veil reaches past its left and right edges, a little above and below for the rim.
|
|
451
|
+
const box: [number, number, number, number] = [Math.max(0, mx0 - MIST_PAD), Math.max(0, my0 - 32), Math.min(W, mx1 + MIST_PAD), Math.min(H, my1 + 32)];
|
|
452
|
+
const region = { left: box[0], top: box[1], width: box[2] - box[0], height: box[3] - box[1] };
|
|
453
|
+
// The fog's own colour: the painting where it is foggiest.
|
|
454
|
+
const sum = [0, 0, 0, 0];
|
|
455
|
+
for (let i = 0; i < density.length; i++) {
|
|
456
|
+
const w = density[i] > 160 ? density[i] : 0;
|
|
457
|
+
for (let c = 0; c < 3; c++) sum[c] += w * rgb[i * 3 + c];
|
|
458
|
+
sum[3] += w;
|
|
459
|
+
}
|
|
460
|
+
const colour = sum[3] ? sum.slice(0, 3).map((v) => Math.round(v / sum[3])) : [220, 224, 230];
|
|
461
|
+
// Lifted a little toward white, so the veil shows over its own fog.
|
|
462
|
+
const veil = colour.map((v) => Math.round(v + (255 - v) * 0.25));
|
|
463
|
+
// Wisps: the veil is the fog's density broken by soft wide streaks, so even an even fog has patches to drift.
|
|
464
|
+
const wisp = wisps(place.key, region.width, region.height);
|
|
465
|
+
const cut = Buffer.from((await sharp(density, { raw: { width: W, height: H, channels: 1 } }).extract(region).extractChannel(0).raw().toBuffer()).map((v, i) => Math.round(v * wisp[i])));
|
|
466
|
+
const tex = await sharp({ create: { width: region.width, height: region.height, channels: 3, background: { r: veil[0], g: veil[1], b: veil[2] } } }).joinChannel(cut, { raw: { width: region.width, height: region.height, channels: 1 } }).png().toBuffer();
|
|
467
|
+
// Where the veil may show: the fog grown and feathered wide, so drifting wisps fade out at its edge.
|
|
468
|
+
const reach = await sharp(dilate(mask, 24).bits.map((b) => b * 255), { raw: { width: W, height: H, channels: 1 } }).blur(20).extract(region).extractChannel(0).raw().toBuffer();
|
|
469
|
+
const soft = await sharp({ create: { width: region.width, height: region.height, channels: 3, background: '#ffffff' } }).joinChannel(reach, { raw: { width: region.width, height: region.height, channels: 1 } }).png().toBuffer();
|
|
470
|
+
const drift = depth.fog === 'left' ? -1 : depth.fog === 'right' ? 1 : null;
|
|
471
|
+
return { tex, mask: soft, box, phrase, drift, speed: depth.fogSpeed ?? 1 };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A plume bigger than this share of the frame is a cloud or a fog bank, not a chimney's smoke. */
|
|
475
|
+
const SMOKE_MAX_SHARE = 0.06;
|
|
476
|
+
|
|
477
|
+
export type Smoke = { png: Buffer; box: [number, number, number, number] };
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Painted smoke (from chimneys, a fire, a ship's funnel) to set curling: SAM finds it on the backdrop, and each
|
|
481
|
+
* plume becomes a copy of the painting round it with a feathered edge. The page bends its rows sideways in a
|
|
482
|
+
* wave that rises through it, more the higher it gets, so the smoke curls and drifts while its foot stays put.
|
|
483
|
+
* Free when the place has none: one SAM call.
|
|
484
|
+
*/
|
|
485
|
+
export async function smokeSheet(memo: Memo, place: Place, backdrop: Buffer, notSmoke: Mask): Promise<Smoke[]> {
|
|
486
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
487
|
+
const hits = await samFind(memo, { label: `smoke:${place.key}`, image: backdrop, prompt: 'smoke' });
|
|
488
|
+
const all = subtract(unionAll(W, H, hits.map((f) => f.mask)), notSmoke);
|
|
489
|
+
const rgba = await sharp(backdrop).resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer();
|
|
490
|
+
const out: Smoke[] = [];
|
|
491
|
+
for (const plume of components(all)) {
|
|
492
|
+
const px = maskArea(plume);
|
|
493
|
+
if (px < 300 || px > SMOKE_MAX_SHARE * W * H) continue;
|
|
494
|
+
// The wave moves rows up to ~10 px: the copy reaches past the plume by that, and fades out at its rim.
|
|
495
|
+
const reach = dilate(plume, 14);
|
|
496
|
+
const [x0, y0, x1, y1] = boxOf(reach);
|
|
497
|
+
const region = { left: x0, top: y0, width: x1 - x0, height: y1 - y0 };
|
|
498
|
+
const alpha = await sharp(Buffer.from(reach.bits.map((b) => b * 255)), { raw: { width: W, height: H, channels: 1 } }).blur(6).extract(region).extractChannel(0).raw().toBuffer();
|
|
499
|
+
const cut = await sharp(rgba, { raw: { width: W, height: H, channels: 4 } }).extract(region).removeAlpha().joinChannel(alpha, { raw: { width: region.width, height: region.height, channels: 1 } }).png().toBuffer();
|
|
500
|
+
out.push({ png: cut, box: [x0, y0, x1, y1] });
|
|
501
|
+
if (out.length >= 8) break;
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Painted boats on the water that are not cards (moored far off, candle boats on a river): SAM finds them on the
|
|
508
|
+
* backdrop, each one touching the water becomes a feathered copy of the painting round it, and the page rocks
|
|
509
|
+
* it on the swell a pixel or two, so a harbour's boats ride as one. Free beyond one SAM call.
|
|
510
|
+
*/
|
|
511
|
+
export async function floatSheet(memo: Memo, place: Place, backdrop: Buffer, notFloat: Mask, water?: Mask): Promise<Smoke[]> {
|
|
512
|
+
if (!water) return [];
|
|
513
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
514
|
+
const hits = await samFind(memo, { label: `floats:${place.key}`, image: backdrop, prompt: 'boat' });
|
|
515
|
+
const all = subtract(unionAll(W, H, hits.map((f) => f.mask)), notFloat);
|
|
516
|
+
const rgba = await sharp(backdrop).resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer();
|
|
517
|
+
// A far boat sits on the water's far edge, where the water mask often stops short of the shore.
|
|
518
|
+
const wetAround = dilate(water, 28);
|
|
519
|
+
const out: Smoke[] = [];
|
|
520
|
+
for (const boat of components(all)) {
|
|
521
|
+
const px = maskArea(boat);
|
|
522
|
+
if (px < 150 || px > 0.04 * W * H || maskArea(intersect(boat, wetAround)) < 0.15 * px) continue;
|
|
523
|
+
const reach = dilate(boat, 5);
|
|
524
|
+
const [x0, y0, x1, y1] = boxOf(reach);
|
|
525
|
+
const region = { left: x0, top: y0, width: x1 - x0, height: y1 - y0 };
|
|
526
|
+
const alpha = await sharp(Buffer.from(reach.bits.map((b) => b * 255)), { raw: { width: W, height: H, channels: 1 } }).blur(2).extract(region).extractChannel(0).raw().toBuffer();
|
|
527
|
+
out.push({ png: await sharp(rgba, { raw: { width: W, height: H, channels: 4 } }).extract(region).removeAlpha().joinChannel(alpha, { raw: { width: region.width, height: region.height, channels: 1 } }).png().toBuffer(), box: [x0, y0, x1, y1] });
|
|
528
|
+
if (out.length >= 24) break;
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Fills `hole` with paint copied from beside it: each piece of the hole takes the patch one hole-width (and a
|
|
535
|
+
* little) to its left, else to its right, further out if that is hole too, as one piece, so the fill keeps the
|
|
536
|
+
* paper's grain, the waves and the streaks around it instead of a blur or row-by-row smears. What no copy reaches
|
|
537
|
+
* is smeared. Only `pieces` are filled; the rest of `hole` is only never copied from.
|
|
538
|
+
*/
|
|
539
|
+
export function cloneFill(rgb: Uint8Array, W: number, H: number, hole: Mask, pieces: Mask = hole): Uint8Array {
|
|
540
|
+
const out = rgb.slice();
|
|
541
|
+
const left = emptyMask(W, H);
|
|
542
|
+
for (const piece of components(pieces)) {
|
|
543
|
+
const [x0, , x1] = boxOf(piece);
|
|
544
|
+
const step = x1 - x0 + 6;
|
|
545
|
+
const tries = [-step, step, -2 * step, 2 * step, -3 * step, 3 * step];
|
|
546
|
+
for (let i = 0; i < piece.bits.length; i++) {
|
|
547
|
+
if (!piece.bits[i]) continue;
|
|
548
|
+
const [x, y] = [i % W, (i / W) | 0];
|
|
549
|
+
const dx = tries.find((d) => x + d >= 0 && x + d < W && !hole.bits[y * W + x + d]);
|
|
550
|
+
if (dx === undefined) left.bits[i] = 1;
|
|
551
|
+
else for (let c = 0; c < 3; c++) out[i * 3 + c] = rgb[(i + dx) * 3 + c];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return maskArea(left) ? smearFill(out, W, H, left, subtract(emptyMaskAll(W, H), hole)) : out;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Fills `hole` row by row with the paint mirrored across its edge (from the left of it, else the right): for a wide
|
|
559
|
+
* hole beside a picture's part (the cliffs beside the clouds), whose edge runs much the same way down the rows, so
|
|
560
|
+
* the mirror is one coherent reflection. What no row reaches is smeared.
|
|
561
|
+
*/
|
|
562
|
+
export function mirrorFill(rgb: Uint8Array, W: number, H: number, hole: Mask): Uint8Array {
|
|
563
|
+
const out = rgb.slice();
|
|
564
|
+
const left = emptyMask(W, H);
|
|
565
|
+
for (let y = 0; y < H; y++) {
|
|
566
|
+
const row = y * W;
|
|
567
|
+
for (let x = 0; x < W; x++) {
|
|
568
|
+
if (!hole.bits[row + x]) continue;
|
|
569
|
+
const a = x;
|
|
570
|
+
while (x < W && hole.bits[row + x]) x++;
|
|
571
|
+
const b = x;
|
|
572
|
+
// The paint beside the run, on whichever side there is more of it.
|
|
573
|
+
let [l, r] = [0, 0];
|
|
574
|
+
while (a - 1 - l >= 0 && !hole.bits[row + a - 1 - l]) l++;
|
|
575
|
+
while (b + r < W && !hole.bits[row + b + r]) r++;
|
|
576
|
+
const onLeft = l >= r;
|
|
577
|
+
const span = onLeft ? l : r;
|
|
578
|
+
for (let k = a; k < b; k++) {
|
|
579
|
+
if (!span) {
|
|
580
|
+
left.bits[row + k] = 1;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
// Mirrored back and forth over that paint, however wide the run is.
|
|
584
|
+
const m = (onLeft ? k - a : b - 1 - k) % (2 * span);
|
|
585
|
+
const off = m < span ? m : 2 * span - 1 - m;
|
|
586
|
+
const src = onLeft ? a - 1 - off : b + off;
|
|
587
|
+
for (let c = 0; c < 3; c++) out[(row + k) * 3 + c] = rgb[(row + src) * 3 + c];
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return maskArea(left) ? smearFill(out, W, H, left, subtract(emptyMaskAll(W, H), hole)) : out;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** A painted waterfall (any thing named so): the page sets its water falling. */
|
|
595
|
+
export const FALLING = /\b(water ?falls?|cascades?|falls|weir|spillway|wodospad\w*|kaskad\w*)\b/i;
|
|
596
|
+
/** Painted animals the page sets swimming or flying as small cards of their own. */
|
|
597
|
+
export const CRITTER = /\b(fish(es)?|goldfish|carp|trout|salmon|koi|eels?|dolphins?|whales?|seals?|ducks?|ducklings?|swans?|geese|goose|frogs?|birds?|gulls?|seagulls?|sparrows?|crows?|ravens?|pigeons?|doves?|swallows?|owls?|bats?|butterfl(y|ies)|dragonfl(y|ies)|jellyfish|ryb\w*|ptak\w*|mew\w*|kacz\w*|łabęd\w*|wron\w*|motyl\w*)\b/i;
|
|
598
|
+
|
|
599
|
+
export type Fall = { png: Buffer; box: [number, number, number, number] };
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A painted waterfall for the page to set pouring: its pixels (from the backdrop) under its mask, feathered, over
|
|
603
|
+
* its box. The page scrolls its ribbons down and churns foam at its foot, so only paint inside the fall moves.
|
|
604
|
+
*/
|
|
605
|
+
export async function fallSheet(backdrop: Buffer, mask: Mask): Promise<Fall | undefined> {
|
|
606
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
607
|
+
if (maskArea(mask) < 1500) return undefined;
|
|
608
|
+
const [x0, y0, x1, y1] = boxOf(mask);
|
|
609
|
+
const region = { left: x0, top: y0, width: x1 - x0, height: y1 - y0 };
|
|
610
|
+
const rgba = await sharp(backdrop).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer();
|
|
611
|
+
const alpha = await sharp(await maskToPng(dilate(mask, -1))).blur(1.5).extract(region).extractChannel(0).raw().toBuffer();
|
|
612
|
+
const png = await sharp(rgba, { raw: { width: W, height: H, channels: 3 } }).extract(region).joinChannel(alpha, { raw: { width: region.width, height: region.height, channels: 1 } }).png().toBuffer();
|
|
613
|
+
return { png, box: [x0, y0, x1, y1] };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export type Critter = { png: Buffer; box: [number, number, number, number]; medium: 'water' | 'air'; facing: 1 | -1 };
|
|
617
|
+
|
|
618
|
+
/** Which way a painted animal looks: its eye (the brightest small spot) lies toward its head. Right when unsure. */
|
|
619
|
+
export function facingOf(rgb: Uint8Array, W: number, m: Mask): 1 | -1 {
|
|
620
|
+
const [x0, y0, x1, y1] = boxOf(m);
|
|
621
|
+
const lumas: number[] = [];
|
|
622
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) if (m.bits[y * W + x]) lumas.push(0.3 * rgb[(y * W + x) * 3] + 0.59 * rgb[(y * W + x) * 3 + 1] + 0.11 * rgb[(y * W + x) * 3 + 2]);
|
|
623
|
+
lumas.sort((a, b) => a - b);
|
|
624
|
+
const bright = Math.max(170, lumas[Math.floor(lumas.length * 0.97)] ?? 255);
|
|
625
|
+
let [sx, n] = [0, 0];
|
|
626
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
627
|
+
const i = y * W + x;
|
|
628
|
+
if (m.bits[i] && 0.3 * rgb[i * 3] + 0.59 * rgb[i * 3 + 1] + 0.11 * rgb[i * 3 + 2] >= bright) [sx, n] = [sx + x, n + 1];
|
|
629
|
+
}
|
|
630
|
+
if (n < 4) return 1;
|
|
631
|
+
return sx / n < (x0 + x1) / 2 - 0.08 * (x1 - x0) ? -1 : 1;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Painted animals (fish in the river, gulls over the sea) as small cards of their own: each piece of the thing's
|
|
636
|
+
* mask a feathered copy of the painting, in the water or the air, and the way it faces. The page sets them
|
|
637
|
+
* swimming or flying; the backdrop and the water have them painted out (see cloneFill).
|
|
638
|
+
*/
|
|
639
|
+
export async function critterSheet(painting: Buffer, mask: Mask, water?: Mask): Promise<Critter[]> {
|
|
640
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
641
|
+
const rgb = new Uint8Array(await sharp(painting).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
|
|
642
|
+
const wetAround = water && dilate(water, 20);
|
|
643
|
+
const out: Critter[] = [];
|
|
644
|
+
for (const one of components(mask)) {
|
|
645
|
+
const px = maskArea(one);
|
|
646
|
+
if (px < 150 || px > 0.05 * W * H) continue;
|
|
647
|
+
const reach = dilate(one, 2);
|
|
648
|
+
const [x0, y0, x1, y1] = boxOf(reach);
|
|
649
|
+
const region = { left: x0, top: y0, width: x1 - x0, height: y1 - y0 };
|
|
650
|
+
const alpha = await sharp(await maskToPng(reach)).blur(1).extract(region).extractChannel(0).raw().toBuffer();
|
|
651
|
+
const png = await sharp(Buffer.from(rgb), { raw: { width: W, height: H, channels: 3 } }).extract(region).joinChannel(alpha, { raw: { width: region.width, height: region.height, channels: 1 } }).png().toBuffer();
|
|
652
|
+
const medium = wetAround && maskArea(intersect(one, wetAround)) >= 0.3 * px ? 'water' : 'air';
|
|
653
|
+
out.push({ png, box: [x0, y0, x1, y1], medium, facing: facingOf(rgb, W, one) });
|
|
654
|
+
if (out.length >= 12) break;
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** How fast each cloud band drifts against the one above it: the top band is nearest the eye and drifts fastest. */
|
|
660
|
+
const CLOUD_SPEEDS = [1, 0.74, 0.55, 0.42];
|
|
661
|
+
/** A band's rim (its torn bottom edge) is looked for this far above and below the row picked for it. */
|
|
662
|
+
const RIM_SEARCH = 28;
|
|
663
|
+
/** A rim is a real torn edge when its light-over-dark step averages this much luma along the way. */
|
|
664
|
+
export const RIM_MIN = 14;
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The path across the picture, one row per column between rows lo and hi, a row up or down per column at most,
|
|
668
|
+
* along which `edge` (W wide) sums highest, and how strong it is on average along the way.
|
|
669
|
+
*/
|
|
670
|
+
export function rimPath(edge: Float32Array, W: number, lo: number, hi: number): { path: Float32Array; strength: number } {
|
|
671
|
+
const n = hi - lo + 1;
|
|
672
|
+
const score = new Float32Array(n * W);
|
|
673
|
+
const from = new Int8Array(n * W);
|
|
674
|
+
for (let r = 0; r < n; r++) score[r] = edge[(lo + r) * W];
|
|
675
|
+
for (let x = 1; x < W; x++) {
|
|
676
|
+
for (let r = 0; r < n; r++) {
|
|
677
|
+
let [best, step] = [score[r * W + x - 1], 0];
|
|
678
|
+
if (r > 0 && score[(r - 1) * W + x - 1] > best) [best, step] = [score[(r - 1) * W + x - 1], -1];
|
|
679
|
+
if (r < n - 1 && score[(r + 1) * W + x - 1] > best) [best, step] = [score[(r + 1) * W + x - 1], 1];
|
|
680
|
+
score[r * W + x] = best + edge[(lo + r) * W + x];
|
|
681
|
+
from[r * W + x] = step;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
let r = 0;
|
|
685
|
+
for (let k = 1; k < n; k++) if (score[k * W + W - 1] > score[r * W + W - 1]) r = k;
|
|
686
|
+
const strength = score[r * W + W - 1] / W;
|
|
687
|
+
const path = new Float32Array(W);
|
|
688
|
+
for (let x = W - 1; x >= 0; x--) {
|
|
689
|
+
path[x] = lo + r;
|
|
690
|
+
r += from[r * W + x];
|
|
691
|
+
}
|
|
692
|
+
return { path, strength };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export type CloudBand = { png: Buffer; box: [number, number, number, number]; speed: number };
|
|
696
|
+
export type Clouds = { bands: CloudBand[]; clip: Buffer; keep?: Buffer; box: [number, number, number, number] };
|
|
697
|
+
|
|
698
|
+
const CloudArea = z.object({ areas: z.array(z.object({ outline: z.array(z.tuple([z.number(), z.number()])).min(3) })).max(3) });
|
|
699
|
+
|
|
700
|
+
/** Warm, saturated paint (a lamp's glow, a lighthouse's rays, the sun): light drawn over the sky, which stays put. */
|
|
701
|
+
function warm(r: number, g: number, b: number): boolean {
|
|
702
|
+
const max = Math.max(r, g, b);
|
|
703
|
+
const min = Math.min(r, g, b);
|
|
704
|
+
return max > 110 && (max - min) / max > 0.35 && r >= b + 30 && g >= b;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Where to cut the sky into bands: rows where many columns step from a light rim down to darker paper (the torn
|
|
709
|
+
* white edge at the foot of a sheet of cloud), the strongest first, far enough apart. Row scores per y.
|
|
710
|
+
*/
|
|
711
|
+
export function bandCuts(score: Float32Array, y0: number, y1: number, most = 3): number[] {
|
|
712
|
+
const span = y1 - y0;
|
|
713
|
+
const gap = Math.max(50, span / 6);
|
|
714
|
+
const smooth = new Float32Array(score.length);
|
|
715
|
+
for (let y = 0; y < score.length; y++) {
|
|
716
|
+
let [s, n] = [0, 0];
|
|
717
|
+
for (let k = Math.max(0, y - 4); k <= Math.min(score.length - 1, y + 4); k++) [s, n] = [s + score[k], n + 1];
|
|
718
|
+
smooth[y] = s / n;
|
|
719
|
+
}
|
|
720
|
+
const inside = [...smooth.slice(y0, y1)].sort((a, b) => a - b);
|
|
721
|
+
const floor = 1.4 * (inside[Math.floor(inside.length / 2)] ?? 0) + 1e-6;
|
|
722
|
+
const order = Array.from({ length: span }, (_, k) => y0 + k).filter((y) => y - y0 >= 40 && y1 - y >= 30 && smooth[y] > floor).sort((a, b) => smooth[b] - smooth[a]);
|
|
723
|
+
const cuts: number[] = [];
|
|
724
|
+
for (const y of order) {
|
|
725
|
+
if (cuts.length >= most) break;
|
|
726
|
+
if (cuts.every((c) => Math.abs(c - y) >= gap)) cuts.push(y);
|
|
727
|
+
}
|
|
728
|
+
return cuts.sort((a, b) => a - b);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The painted clouds as bands, one behind another, for the page to drift with the wind at their own speeds,
|
|
733
|
+
* like torn-paper flats on a stage. Spark outlines where the clouds are (SAM knows no paper cloud); the bands are
|
|
734
|
+
* cut along the torn rims read from the pixels, each band's own rim its silhouette, and filled above it (mirrored
|
|
735
|
+
* from its own paper) so a band never shows a gap under the one over it. `keep`: painted things in the sky
|
|
736
|
+
* (wind swirls) that stay put; warm light (rays, glows) stays too. Cards (a lighthouse) are already out of the backdrop.
|
|
737
|
+
*/
|
|
738
|
+
export async function cloudSheet(memo: Memo, place: Place, backdrop: Buffer, notCloud: Mask, keep: Mask): Promise<Clouds | undefined> {
|
|
739
|
+
const [W, H]: [number, number] = [FRAME.width, FRAME.height];
|
|
740
|
+
const prompt = `This is a painted game scene, ${W}x${H} px. Are there clouds painted in its sky (soft, painted, or layered torn-paper clouds)?
|
|
741
|
+
Answer with JSON only: {"areas": [{"outline": [[x, y], ...]}]}, 8 to 20 points in px around the part of the picture that is clouds and sky (not hills, cliffs, rocks, trees, buildings, the sea or the ground), at most 3 areas; or {"areas": []} when no clouds are painted.`;
|
|
742
|
+
const { areas } = CloudArea.parse(await askSparkJson(memo, { schema: CloudArea, label: `clouds:${place.key}`, prompt, images: [backdrop] }));
|
|
743
|
+
if (!areas.length) return undefined;
|
|
744
|
+
const area = subtract(unionAll(W, H, areas.map((a) => polygonMask(W, H, a.outline))), notCloud);
|
|
745
|
+
if (maskArea(area) < 0.04 * W * H) return undefined;
|
|
746
|
+
const [, sy0, , sy1] = boxOf(area);
|
|
747
|
+
const rgb0 = new Uint8Array(await sharp(backdrop).resize(W, H, { fit: 'fill' }).removeAlpha().raw().toBuffer());
|
|
748
|
+
// What stays put in the sky: painted things there and warm light, a hair wider.
|
|
749
|
+
const lit = emptyMask(W, H);
|
|
750
|
+
for (let i = 0; i < W * H; i++) if (area.bits[i] && warm(rgb0[i * 3], rgb0[i * 3 + 1], rgb0[i * 3 + 2])) lit.bits[i] = 1;
|
|
751
|
+
const pinned = intersect(dilate(unionAll(W, H, [keep, lit]), 3), area);
|
|
752
|
+
// The band paper: the sky with what stays put, and all that is not cloud in the sky's rows, copied over from the clouds beside.
|
|
753
|
+
const rows = emptyMask(W, H);
|
|
754
|
+
rows.bits.fill(1, sy0 * W, sy1 * W);
|
|
755
|
+
// A band drifts across the picture, so what lies beside the clouds in its rows (a cliff) slides into view too.
|
|
756
|
+
const outside = subtract(rows, area);
|
|
757
|
+
const rgb = mirrorFill(cloneFill(rgb0, W, H, union(pinned, outside), pinned), W, H, outside);
|
|
758
|
+
const L = new Float32Array(W * H);
|
|
759
|
+
for (let i = 0; i < W * H; i++) L[i] = 0.3 * rgb[i * 3] + 0.59 * rgb[i * 3 + 1] + 0.11 * rgb[i * 3 + 2];
|
|
760
|
+
// A torn rim: light just above, darker paper just below.
|
|
761
|
+
const edge = new Float32Array(W * H);
|
|
762
|
+
const score = new Float32Array(H);
|
|
763
|
+
for (let y = Math.max(2, sy0); y < Math.min(H - 3, sy1); y++) {
|
|
764
|
+
let [s, n] = [0, 0];
|
|
765
|
+
for (let x = 0; x < W; x++) {
|
|
766
|
+
const i = y * W + x;
|
|
767
|
+
if (!area.bits[i]) continue;
|
|
768
|
+
edge[i] = Math.max(0, L[i - 2 * W] - L[i + 3 * W]);
|
|
769
|
+
[s, n] = [s + edge[i], n + 1];
|
|
770
|
+
}
|
|
771
|
+
score[y] = n > W * 0.2 ? s / n : 0;
|
|
772
|
+
}
|
|
773
|
+
const cuts = bandCuts(score, sy0, sy1);
|
|
774
|
+
// Each cut follows its rim across the picture: the path of strongest light-over-dark steps near the cut's row,
|
|
775
|
+
// a pixel up or down per column at most, so the band's edge is the paper's torn edge and never a notch. A cut
|
|
776
|
+
// whose path finds no rim along most of the way (it runs through one sheet of paper) would tear it: none there.
|
|
777
|
+
const paths = cuts.map((c) => rimPath(edge, W, Math.max(sy0 + 1, c - RIM_SEARCH), Math.min(sy1 - 2, c + RIM_SEARCH)));
|
|
778
|
+
const rims = paths.filter((r) => r.strength >= RIM_MIN).map((r) => r.path.map((y) => y + 2));
|
|
779
|
+
if (process.env.DIORAMA_DEBUG_CLOUDS) console.log(` ${place.key}: cloud cuts ${cuts.join(', ')} rims ${paths.map((p) => p.strength.toFixed(1)).join(', ')}`);
|
|
780
|
+
const tops = [new Float32Array(W).fill(sy0), ...rims];
|
|
781
|
+
const bottoms = [...rims, new Float32Array(W).fill(sy1)];
|
|
782
|
+
const bands: CloudBand[] = [];
|
|
783
|
+
for (let k = 0; k < tops.length; k++) {
|
|
784
|
+
const [top, bottom] = [tops[k], bottoms[k]];
|
|
785
|
+
const yEnd = Math.min(H, Math.ceil(Math.max(...bottom)) + 2);
|
|
786
|
+
const h = yEnd - sy0;
|
|
787
|
+
if (h < 8) continue;
|
|
788
|
+
const rgba = Buffer.alloc(W * h * 4);
|
|
789
|
+
for (let x = 0; x < W; x++) {
|
|
790
|
+
const [t, b] = [Math.round(top[x]), Math.round(bottom[x])];
|
|
791
|
+
const tall = Math.max(1, b - t);
|
|
792
|
+
for (let y = sy0; y < yEnd; y++) {
|
|
793
|
+
// Above its top the band goes on in its own paper, mirrored back and forth, under the band above.
|
|
794
|
+
let yy = y;
|
|
795
|
+
if (y < t) {
|
|
796
|
+
const m = (t - y) % (2 * tall);
|
|
797
|
+
yy = m < tall ? t + m : b - 1 - (m - tall);
|
|
798
|
+
}
|
|
799
|
+
yy = Math.max(0, Math.min(H - 1, yy));
|
|
800
|
+
const o = ((y - sy0) * W + x) * 4;
|
|
801
|
+
rgba[o] = rgb[(yy * W + x) * 3];
|
|
802
|
+
rgba[o + 1] = rgb[(yy * W + x) * 3 + 1];
|
|
803
|
+
rgba[o + 2] = rgb[(yy * W + x) * 3 + 2];
|
|
804
|
+
rgba[o + 3] = y < b ? 255 : y < b + 2 ? 120 : 0;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
bands.push({ png: await sharp(rgba, { raw: { width: W, height: h, channels: 4 } }).png().toBuffer(), box: [0, sy0, W, yEnd], speed: CLOUD_SPEEDS[Math.min(k, CLOUD_SPEEDS.length - 1)] });
|
|
808
|
+
}
|
|
809
|
+
// One band only (no rim found) still drifts as a whole.
|
|
810
|
+
if (!bands.length) return undefined;
|
|
811
|
+
const region = { left: 0, top: sy0, width: W, height: sy1 - sy0 };
|
|
812
|
+
// Where the clouds may show (the page cuts with its alpha): not over the hills, cliffs or sea in front of them.
|
|
813
|
+
const clipAlpha = await sharp(await maskToPng(area)).blur(1.5).extract(region).extractChannel(0).raw().toBuffer();
|
|
814
|
+
const clipPng = await sharp({ create: { width: W, height: region.height, channels: 3, background: '#ffffff' } }).joinChannel(clipAlpha, { raw: { width: W, height: region.height, channels: 1 } }).png().toBuffer();
|
|
815
|
+
let keepPng: Buffer | undefined;
|
|
816
|
+
if (maskArea(pinned)) {
|
|
817
|
+
const a = await sharp(await maskToPng(pinned)).blur(1.5).extract(region).extractChannel(0).raw().toBuffer();
|
|
818
|
+
keepPng = await sharp(Buffer.from(rgb0), { raw: { width: W, height: H, channels: 3 } }).extract(region).joinChannel(a, { raw: { width: W, height: region.height, channels: 1 } }).png().toBuffer();
|
|
819
|
+
}
|
|
820
|
+
// Bottom band first: each band above lies over the one below it.
|
|
821
|
+
return { bands: bands.reverse(), clip: clipPng, keep: keepPng, box: [0, sy0, W, sy1] };
|
|
822
|
+
}
|