@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,417 @@
|
|
|
1
|
+
// The painted figure walked as one sheet of rubber, not cut into parts: a fine mesh over the painting, each point
|
|
2
|
+
// pulled by the bones near it (hip, thighs, shins, boots, arms), so a coat, an apron or a skirt stretches and swings
|
|
3
|
+
// with the legs instead of tearing, and a leg never comes off its trousers. The walk is drawn here once into a
|
|
4
|
+
// sheet of frames (and the untouched painting as the standing frame); the runtime only plays the cells.
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { emptyMask, maskArea, type Mask } from '../image/mask.ts';
|
|
7
|
+
import { components, dilate, maskBox } from '../image/raster.ts';
|
|
8
|
+
import { swingPose, type PartName, type PartPose, type Pose, type Rig } from '../play/rig.ts';
|
|
9
|
+
|
|
10
|
+
type P = [number, number];
|
|
11
|
+
|
|
12
|
+
/** What each painted pixel belongs to; its bones pull it. */
|
|
13
|
+
export const enum Part { None, Body, NearArm, FarArm, NearLeg, FarLeg, NearBoot, FarBoot }
|
|
14
|
+
|
|
15
|
+
/** Back to front: the far side, the near leg (behind a coat or an apron), the body, the near arm on top. */
|
|
16
|
+
const LAYER: Record<Part, number> = { [Part.None]: 0, [Part.FarArm]: 0, [Part.FarLeg]: 0, [Part.FarBoot]: 0, [Part.NearLeg]: 1, [Part.NearBoot]: 1, [Part.Body]: 2, [Part.NearArm]: 3 };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One label per pixel of `silhouette`: the masks in order (a later one wins), and every pixel none of them
|
|
20
|
+
* covers (a held lantern, a net of fish, an apron string) takes the label of the nearest labelled pixel.
|
|
21
|
+
*/
|
|
22
|
+
export function labelPixels(silhouette: Mask, masks: [Part, Mask][], person?: Mask): Uint8Array {
|
|
23
|
+
const { width: W, height: H } = silhouette;
|
|
24
|
+
const label = new Uint8Array(W * H);
|
|
25
|
+
for (const [part, m] of masks) m.bits.forEach((b, i) => b && silhouette.bits[i] && (label[i] = part));
|
|
26
|
+
// Something held (outside SAM's person) goes whole with the hand that holds it, else with what it touches most.
|
|
27
|
+
if (person) {
|
|
28
|
+
const held = emptyMask(W, H);
|
|
29
|
+
silhouette.bits.forEach((b, i) => (held.bits[i] = b && !person.bits[i] && !label[i] ? 1 : 0));
|
|
30
|
+
for (const c of components(held)) {
|
|
31
|
+
const touch = new Map<number, number>();
|
|
32
|
+
const ring = dilate(c, 2);
|
|
33
|
+
ring.bits.forEach((b, i) => b && !c.bits[i] && label[i] && touch.set(label[i], (touch.get(label[i]) ?? 0) + 1));
|
|
34
|
+
if (!touch.size) continue;
|
|
35
|
+
// Held up in both hands (a big slice of toast over the head), it goes with the body: neither arm owns it.
|
|
36
|
+
const arms = [Part.NearArm, Part.FarArm].filter((a) => touch.has(a)).sort((a, b) => touch.get(b)! - touch.get(a)!);
|
|
37
|
+
const arm = arms.length === 2 && maskArea(c) > 0.5 * maskArea(person) ? Part.Body : arms[0];
|
|
38
|
+
const part = arm ?? [...touch].sort((a, b) => b[1] - a[1])[0][0];
|
|
39
|
+
c.bits.forEach((b, i) => b && (label[i] = part));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Breadth-first from every labelled pixel through the silhouette.
|
|
43
|
+
let front: number[] = [];
|
|
44
|
+
label.forEach((l, i) => l && front.push(i));
|
|
45
|
+
while (front.length) {
|
|
46
|
+
const next: number[] = [];
|
|
47
|
+
for (const i of front) {
|
|
48
|
+
const x = i % W;
|
|
49
|
+
for (const n of [x > 0 ? i - 1 : -1, x < W - 1 ? i + 1 : -1, i - W, i + W]) {
|
|
50
|
+
if (n < 0 || n >= W * H || !silhouette.bits[n] || label[n]) continue;
|
|
51
|
+
label[n] = label[i];
|
|
52
|
+
next.push(n);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
front = next;
|
|
56
|
+
}
|
|
57
|
+
// Pieces cut off from everything (a speck SAM left) go with the body.
|
|
58
|
+
silhouette.bits.forEach((b, i) => b && !label[i] && (label[i] = Part.Body));
|
|
59
|
+
return label;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function segDist([x, y]: P, [ax, ay]: P, [bx, by]: P): { d: number; t: number } {
|
|
63
|
+
const dx = bx - ax;
|
|
64
|
+
const dy = by - ay;
|
|
65
|
+
const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy || 1)));
|
|
66
|
+
return { d: Math.hypot(x - ax - t * dx, y - ay - t * dy), t };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const smooth = (u: number) => (u <= 0 ? 0 : u >= 1 ? 1 : u * u * (3 - 2 * u));
|
|
70
|
+
|
|
71
|
+
type Weights = [PartName, number][];
|
|
72
|
+
|
|
73
|
+
/** How much each bone pulls a point of the figure, in the rig's own coordinates. */
|
|
74
|
+
export function weightsAt(rig: Rig, p: P, part: Part): Weights {
|
|
75
|
+
const H = rig.height;
|
|
76
|
+
const leg = (side: 'near' | 'far', boot: boolean): Weights => {
|
|
77
|
+
const { knee, ankle } = rig.leg[side];
|
|
78
|
+
const thigh = `${side}Thigh` as PartName;
|
|
79
|
+
const shin = `${side}Shin` as PartName;
|
|
80
|
+
const bootName = `${side}Boot` as PartName;
|
|
81
|
+
if (boot) {
|
|
82
|
+
// A boot keeps its shape; only its top, where it meets the shin, bends with the shin.
|
|
83
|
+
const s = 1 - smooth((p[1] - ankle[1]) / (0.03 * H));
|
|
84
|
+
return s > 0 ? [[bootName, 1 - s], [shin, s]] : [[bootName, 1]];
|
|
85
|
+
}
|
|
86
|
+
// Along the leg: thigh above the knee, shin below, mixed over a short band round the knee; the bottom of
|
|
87
|
+
// a trouser leg hanging over the boot follows the shin.
|
|
88
|
+
const toKnee = ((p[0] - knee[0]) * (ankle[0] - rig.hip[0]) + (p[1] - knee[1]) * (ankle[1] - rig.hip[1])) / (Math.hypot(ankle[0] - rig.hip[0], ankle[1] - rig.hip[1]) || 1);
|
|
89
|
+
const s = smooth((toKnee + 0.04 * H) / (0.08 * H));
|
|
90
|
+
return [[thigh, 1 - s], [shin, s]];
|
|
91
|
+
};
|
|
92
|
+
switch (part) {
|
|
93
|
+
case Part.NearLeg: return leg('near', false);
|
|
94
|
+
case Part.FarLeg: return leg('far', false);
|
|
95
|
+
case Part.NearBoot: return leg('near', true);
|
|
96
|
+
case Part.FarBoot: return leg('far', true);
|
|
97
|
+
case Part.NearArm:
|
|
98
|
+
case Part.FarArm: {
|
|
99
|
+
// An arm turns at the shoulder: the sleeve's top stays with the body, so the arm never leaves the shoulder.
|
|
100
|
+
const side = part === Part.NearArm ? 'near' : 'far';
|
|
101
|
+
const at = rig.shoulder[side];
|
|
102
|
+
const s = smooth(Math.hypot(p[0] - at[0], p[1] - at[1]) / (0.1 * H));
|
|
103
|
+
return s < 1 ? [[`${side}Arm`, s], ['torso', 1 - s]] : [[`${side}Arm`, 1]];
|
|
104
|
+
}
|
|
105
|
+
default: {
|
|
106
|
+
// The body above the hip is one piece. Below it (a coat's skirt, an apron, a dress, a jumper's hem), the
|
|
107
|
+
// cloth goes more and more with the legs the lower it hangs, each bit with the leg it is nearest.
|
|
108
|
+
const u = 0.45 * smooth((p[1] - rig.hip[1]) / (0.14 * H));
|
|
109
|
+
if (u <= 0) return [['torso', 1]];
|
|
110
|
+
const near = Math.min(segDist(p, rig.hip, rig.leg.near.knee).d, segDist(p, rig.leg.near.knee, rig.leg.near.ankle).d);
|
|
111
|
+
const far = Math.min(segDist(p, rig.hip, rig.leg.far.knee).d, segDist(p, rig.leg.far.knee, rig.leg.far.ankle).d);
|
|
112
|
+
const wn = 1 / (near + 0.02 * H) ** 2;
|
|
113
|
+
const wf = 1 / (far + 0.02 * H) ** 2;
|
|
114
|
+
const out: Weights = [['torso', 1 - u]];
|
|
115
|
+
for (const [side, w] of [['near', wn / (wn + wf)], ['far', wf / (wn + wf)]] as const) {
|
|
116
|
+
// Cloth hangs from the hip: it follows the thigh's swing, not the knee's bend under it.
|
|
117
|
+
out.push([`${side}Thigh` as PartName, u * w]);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const move = ({ from, to, turn }: PartPose, [x, y]: P): P => {
|
|
125
|
+
const c = Math.cos(turn);
|
|
126
|
+
const s = Math.sin(turn);
|
|
127
|
+
const dx = x - from[0];
|
|
128
|
+
const dy = y - from[1];
|
|
129
|
+
return [to[0] + c * dx - s * dy, to[1] + s * dx + c * dy];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type Sheet = { png: Buffer; cols: number; rows: number; frames: number; cell: P; scale: number };
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The walk as a sheet: `frames` cells of one cycle, then the standing figure, each `scale` times the rig's size.
|
|
136
|
+
* `image` is the figure in the rig's coordinates (RGBA, rig.size), `labels` one Part per pixel of it.
|
|
137
|
+
*/
|
|
138
|
+
export async function warpSheet(rig: Rig, image: Buffer, labels: Uint8Array, { frames = 8, px = 520, grid = 4 } = {}): Promise<Sheet> {
|
|
139
|
+
const [W, Hh] = rig.size.map(Math.round);
|
|
140
|
+
const scale = Math.min(1, px / rig.height);
|
|
141
|
+
const cw = Math.round(W * scale);
|
|
142
|
+
const ch = Math.round(Hh * scale);
|
|
143
|
+
// The source, premultiplied, at the sheet's scale (sampled bilinear), and the labels at it.
|
|
144
|
+
const { data: src } = await sharp(image, { raw: { width: W, height: Hh, channels: 4 } }).resize(cw, ch, { fit: 'fill', kernel: 'lanczos3' }).raw().toBuffer({ resolveWithObject: true });
|
|
145
|
+
const pre = new Float32Array(cw * ch * 4);
|
|
146
|
+
for (let i = 0; i < cw * ch; i++) {
|
|
147
|
+
const a = src[i * 4 + 3] / 255;
|
|
148
|
+
pre[i * 4] = src[i * 4] * a; pre[i * 4 + 1] = src[i * 4 + 1] * a; pre[i * 4 + 2] = src[i * 4 + 2] * a; pre[i * 4 + 3] = a;
|
|
149
|
+
}
|
|
150
|
+
const labelAt = (x: number, y: number) => labels[Math.min(Hh - 1, Math.max(0, Math.round(y / scale))) * W + Math.min(W - 1, Math.max(0, Math.round(x / scale)))];
|
|
151
|
+
|
|
152
|
+
// The mesh: vertices every `grid` sheet pixels; each cell's label is the one most of its pixels carry.
|
|
153
|
+
const gx = Math.ceil(cw / grid) + 1;
|
|
154
|
+
const gy = Math.ceil(ch / grid) + 1;
|
|
155
|
+
const cells: { i: number; j: number; layer: number }[] = [];
|
|
156
|
+
const vertexPart = new Uint8Array(gx * gy);
|
|
157
|
+
for (let j = 0; j < gy - 1; j++) {
|
|
158
|
+
for (let i = 0; i < gx - 1; i++) {
|
|
159
|
+
const count = new Map<number, number>();
|
|
160
|
+
let ink = false;
|
|
161
|
+
for (let y = j * grid; y < Math.min(ch, (j + 1) * grid + 1); y++) {
|
|
162
|
+
for (let x = i * grid; x < Math.min(cw, (i + 1) * grid + 1); x++) {
|
|
163
|
+
if (pre[(y * cw + x) * 4 + 3] <= 0.01) continue;
|
|
164
|
+
ink = true;
|
|
165
|
+
const l = labelAt(x, y);
|
|
166
|
+
count.set(l, (count.get(l) ?? 0) + 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!ink) continue;
|
|
170
|
+
const part = [...count].sort((a, b) => b[1] - a[1])[0][0] || Part.Body;
|
|
171
|
+
cells.push({ i, j, layer: LAYER[part as Part] });
|
|
172
|
+
// A vertex takes the label of a cell it belongs to, nearer parts first (a far limb's edge goes with the body).
|
|
173
|
+
for (const v of [j * gx + i, j * gx + i + 1, (j + 1) * gx + i, (j + 1) * gx + i + 1]) if (!vertexPart[v] || LAYER[part as Part] > LAYER[vertexPart[v] as Part]) vertexPart[v] = part;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
cells.sort((a, b) => a.layer - b.layer);
|
|
177
|
+
// Each vertex's bones from the label under it, or its cell's label where it sits on the figure's edge.
|
|
178
|
+
const weights: Weights[] = [];
|
|
179
|
+
for (let j = 0; j < gy; j++) {
|
|
180
|
+
for (let i = 0; i < gx; i++) {
|
|
181
|
+
const sx = i * grid;
|
|
182
|
+
const sy = j * grid;
|
|
183
|
+
const own = sx < cw && sy < ch && pre[(sy * cw + sx) * 4 + 3] > 0.01 ? labelAt(sx, sy) : 0;
|
|
184
|
+
weights.push(weightsAt(rig, [sx / scale, sy / scale], (own || vertexPart[j * gx + i] || Part.Body) as Part));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Weights change smoothly over the cloth: a hem where a leg comes out from under an apron bends over a few
|
|
189
|
+
// cells instead of between two neighbouring vertices (which tears a long thin streak).
|
|
190
|
+
smoothWeights(weights, gx, gy, cells.length ? new Set(cells.flatMap(({ i, j }) => [j * gx + i, j * gx + i + 1, (j + 1) * gx + i, (j + 1) * gx + i + 1])) : new Set(), Math.max(1, Math.round((0.03 * rig.height * scale) / grid)));
|
|
191
|
+
const poses: Pose[] = [...Array(frames).keys()].map((f) => swingPose(rig, f / frames));
|
|
192
|
+
const total = frames + 1;
|
|
193
|
+
const cols = Math.min(total, 5);
|
|
194
|
+
const rows = Math.ceil(total / cols);
|
|
195
|
+
const sheet = Buffer.alloc(cols * cw * rows * ch * 4);
|
|
196
|
+
const SW = cols * cw;
|
|
197
|
+
for (let f = 0; f < total; f++) {
|
|
198
|
+
const ox = (f % cols) * cw;
|
|
199
|
+
const oy = Math.floor(f / cols) * ch;
|
|
200
|
+
if (f === frames) {
|
|
201
|
+
// Standing: the painting itself.
|
|
202
|
+
for (let y = 0; y < ch; y++) src.copy(sheet, ((oy + y) * SW + ox) * 4, y * cw * 4, (y + 1) * cw * 4);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const at = poses[f];
|
|
206
|
+
const dst = new Float32Array(cw * ch * 4);
|
|
207
|
+
const out: P[] = weights.map((w, v) => {
|
|
208
|
+
const p: P = [(v % gx) * grid / scale, Math.floor(v / gx) * grid / scale];
|
|
209
|
+
let x = 0, y = 0, sum = 0;
|
|
210
|
+
for (const [bone, k] of w) {
|
|
211
|
+
if (k <= 0) continue;
|
|
212
|
+
const q = move(at[bone], p);
|
|
213
|
+
x += q[0] * k; y += q[1] * k; sum += k;
|
|
214
|
+
}
|
|
215
|
+
return [(x / (sum || 1)) * scale, (y / (sum || 1)) * scale];
|
|
216
|
+
});
|
|
217
|
+
for (const { i, j } of cells) {
|
|
218
|
+
const a = j * gx + i;
|
|
219
|
+
const quad = [a, a + 1, a + gx + 1, a + gx];
|
|
220
|
+
for (const tri of [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]]) {
|
|
221
|
+
drawTriangle(dst, cw, ch, pre, tri.map((v) => out[v]) as [P, P, P], tri.map((v) => [(v % gx) * grid, Math.floor(v / gx) * grid]) as [P, P, P]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (let y = 0; y < ch; y++) {
|
|
225
|
+
for (let x = 0; x < cw; x++) {
|
|
226
|
+
const s = (y * cw + x) * 4;
|
|
227
|
+
const a = dst[s + 3];
|
|
228
|
+
const o = ((oy + y) * SW + ox + x) * 4;
|
|
229
|
+
if (a <= 0) continue;
|
|
230
|
+
sheet[o] = Math.round(dst[s] / a); sheet[o + 1] = Math.round(dst[s + 1] / a); sheet[o + 2] = Math.round(dst[s + 2] / a); sheet[o + 3] = Math.round(Math.min(1, a) * 255);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const png = await sharp(sheet, { raw: { width: SW, height: rows * ch, channels: 4 } }).png().toBuffer();
|
|
235
|
+
return { png, cols, rows, frames, cell: [cw, ch], scale };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Paints a triangle of the source (at `uv`, sheet pixels) where the mesh moved it (`xy`), over what is drawn. */
|
|
239
|
+
function drawTriangle(dst: Float32Array, W: number, H: number, src: Float32Array, xy: [P, P, P], uv: [P, P, P]) {
|
|
240
|
+
const [[x0, y0], [x1, y1], [x2, y2]] = xy;
|
|
241
|
+
const det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
|
|
242
|
+
if (Math.abs(det) < 1e-6) return;
|
|
243
|
+
const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
|
|
244
|
+
const maxX = Math.min(W - 1, Math.ceil(Math.max(x0, x1, x2)));
|
|
245
|
+
const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
|
|
246
|
+
const maxY = Math.min(H - 1, Math.ceil(Math.max(y0, y1, y2)));
|
|
247
|
+
const eps = -1e-3;
|
|
248
|
+
for (let y = minY; y <= maxY; y++) {
|
|
249
|
+
for (let x = minX; x <= maxX; x++) {
|
|
250
|
+
const px = x + 0.5;
|
|
251
|
+
const py = y + 0.5;
|
|
252
|
+
const b1 = ((px - x0) * (y2 - y0) - (x2 - x0) * (py - y0)) / det;
|
|
253
|
+
const b2 = ((x1 - x0) * (py - y0) - (px - x0) * (y1 - y0)) / det;
|
|
254
|
+
const b0 = 1 - b1 - b2;
|
|
255
|
+
if (b0 < eps || b1 < eps || b2 < eps) continue;
|
|
256
|
+
const u = b0 * uv[0][0] + b1 * uv[1][0] + b2 * uv[2][0] - 0.5;
|
|
257
|
+
const v = b0 * uv[0][1] + b1 * uv[1][1] + b2 * uv[2][1] - 0.5;
|
|
258
|
+
// Bilinear, premultiplied.
|
|
259
|
+
const ux = Math.max(0, Math.min(W - 1, Math.floor(u)));
|
|
260
|
+
const vy = Math.max(0, Math.min(H - 1, Math.floor(v)));
|
|
261
|
+
const ux1 = Math.min(W - 1, ux + 1);
|
|
262
|
+
const vy1 = Math.min(H - 1, vy + 1);
|
|
263
|
+
const fx = Math.max(0, Math.min(1, u - ux));
|
|
264
|
+
const fy = Math.max(0, Math.min(1, v - vy));
|
|
265
|
+
const o = (y * W + x) * 4;
|
|
266
|
+
const s = [0, 0, 0, 0];
|
|
267
|
+
for (let c = 0; c < 4; c++) {
|
|
268
|
+
s[c] = (src[(vy * W + ux) * 4 + c] * (1 - fx) + src[(vy * W + ux1) * 4 + c] * fx) * (1 - fy)
|
|
269
|
+
+ (src[(vy1 * W + ux) * 4 + c] * (1 - fx) + src[(vy1 * W + ux1) * 4 + c] * fx) * fy;
|
|
270
|
+
}
|
|
271
|
+
if (s[3] <= 0) continue;
|
|
272
|
+
// Later layers go on top: premultiplied "over".
|
|
273
|
+
for (let c = 0; c < 4; c++) dst[o + c] = s[c] + dst[o + c] * (1 - s[3]);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Whether ink of `area` px is what the figure stands in, not theirs: a framed scene painted behind them (a cardboard
|
|
280
|
+
* box of stormy sea) touches them and is compact like a lantern, but it is bigger than they are, or a solid patch
|
|
281
|
+
* holding their whole outline. A bubble they float in holds them too, but only as a thin ring.
|
|
282
|
+
*/
|
|
283
|
+
function standsIn(c: Mask, area: number, figure: number, [fx0, fy0, fx1, fy1]: [number, number, number, number]): boolean {
|
|
284
|
+
const [x0, y0, x1, y1] = maskBox(c)!;
|
|
285
|
+
const holds = x0 <= fx0 && y0 <= fy0 && x1 >= fx1 && y1 >= fy1;
|
|
286
|
+
return area > 0.6 * figure || (holds && area / ((x1 - x0 + 1) * (y1 - y0 + 1)) > 0.35);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The figure's whole outline: SAM's person, plus what it holds (a lantern, a net) that stands out from the plain ground, holes filled. */
|
|
290
|
+
export function silhouette(rgba: Buffer, person: Mask, tolerance = 64): Mask {
|
|
291
|
+
const { width: W, height: H } = person;
|
|
292
|
+
// The ground colour: the median of the picture's border.
|
|
293
|
+
const border: number[][] = [];
|
|
294
|
+
for (let x = 0; x < W; x += 4) for (const y of [0, H - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
|
|
295
|
+
for (let y = 0; y < H; y += 4) for (const x of [0, W - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
|
|
296
|
+
const ground = [0, 1, 2].map((c) => border.map((p) => p[c]).sort((a, b) => a - b)[border.length >> 1]);
|
|
297
|
+
const ink = emptyMask(W, H);
|
|
298
|
+
for (let i = 0; i < W * H; i++) ink.bits[i] = Math.hypot(rgba[i * 4] - ground[0], rgba[i * 4 + 1] - ground[1], rgba[i * 4 + 2] - ground[2]) > tolerance ? 1 : 0;
|
|
299
|
+
const near = dilate(person, 3);
|
|
300
|
+
const out = { width: W, height: H, bits: person.bits.slice() };
|
|
301
|
+
person.bits.forEach((b, i) => b && (ink.bits[i] = 0));
|
|
302
|
+
const rim = dilate(person, 4);
|
|
303
|
+
const size = maskArea(person);
|
|
304
|
+
const [px0, py0, px1, py1] = maskBox(person) ?? [0, 0, 0, 0];
|
|
305
|
+
const parts = components(ink);
|
|
306
|
+
const behind = parts.filter((c) => standsIn(c, maskArea(c), size, [px0, py0, px1, py1]));
|
|
307
|
+
// Standing in a painted scene, what shows through a gap in the figure (between the legs) is that scene too.
|
|
308
|
+
const holes = behind.length ? fillHoles(person) : undefined;
|
|
309
|
+
for (const c of parts) {
|
|
310
|
+
if (behind.includes(c) || (holes && c.bits.every((b, i) => !b || holes.bits[i]))) continue;
|
|
311
|
+
let touches = false;
|
|
312
|
+
let outside = 0;
|
|
313
|
+
let area = 0;
|
|
314
|
+
for (let i = 0; i < c.bits.length; i++) {
|
|
315
|
+
if (!c.bits[i]) continue;
|
|
316
|
+
area++;
|
|
317
|
+
touches ||= !!near.bits[i];
|
|
318
|
+
if (!rim.bits[i]) outside++;
|
|
319
|
+
}
|
|
320
|
+
if (!touches) continue;
|
|
321
|
+
// The rim SAM's mask left off the figure's edge always; beyond it, only something compact (a lantern, a
|
|
322
|
+
// bag), not a rail or the haze the figure leans in.
|
|
323
|
+
const [x0, y0, x1, y1] = maskBox(c)!;
|
|
324
|
+
const compact = area / ((x1 - x0 + 1) * (y1 - y0 + 1)) > 0.35;
|
|
325
|
+
if (outside < 50 || compact) c.bits.forEach((b, i) => b && (out.bits[i] = 1));
|
|
326
|
+
else c.bits.forEach((b, i) => b && rim.bits[i] && (out.bits[i] = 1));
|
|
327
|
+
}
|
|
328
|
+
// ... and the gaps fillHoles would close stay open.
|
|
329
|
+
const filled = fillHoles(out);
|
|
330
|
+
if (holes) ink.bits.forEach((b, i) => b && !out.bits[i] && (filled.bits[i] = 0));
|
|
331
|
+
return filled;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* What floats around a figure as part of who they are (a heart beside them, the bubble they sit in, a halo): ink on
|
|
336
|
+
* the plain ground near the figure that does not touch it, so silhouette leaves it out. A figure is painted alone on
|
|
337
|
+
* plain grey, so a sizeable mark close by is theirs (a heart 10%+ of the figure, a bubble's rings 25%); watercolour splashes
|
|
338
|
+
* around the figure (1-2%) are not. Added as drawn, holes not filled: a bubble's
|
|
339
|
+
* outline comes along, not the grey inside it.
|
|
340
|
+
*/
|
|
341
|
+
export function companions(rgba: Buffer, figure: Mask, tolerance = 64): Mask {
|
|
342
|
+
const { width: W, height: H } = figure;
|
|
343
|
+
const box = maskBox(figure);
|
|
344
|
+
if (!box) return figure;
|
|
345
|
+
const border: number[][] = [];
|
|
346
|
+
for (let x = 0; x < W; x += 4) for (const y of [0, H - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
|
|
347
|
+
for (let y = 0; y < H; y += 4) for (const x of [0, W - 1]) border.push([...rgba.subarray((y * W + x) * 4, (y * W + x) * 4 + 3)]);
|
|
348
|
+
const ground = [0, 1, 2].map((c) => border.map((p) => p[c]).sort((a, b) => a - b)[border.length >> 1]);
|
|
349
|
+
const ink = emptyMask(W, H);
|
|
350
|
+
for (let i = 0; i < W * H; i++) ink.bits[i] = !figure.bits[i] && Math.hypot(rgba[i * 4] - ground[0], rgba[i * 4 + 1] - ground[1], rgba[i * 4 + 2] - ground[2]) > tolerance ? 1 : 0;
|
|
351
|
+
const [x0, y0, x1, y1] = box;
|
|
352
|
+
const reach = 0.35 * (y1 - y0);
|
|
353
|
+
const area = maskArea(figure);
|
|
354
|
+
const out = { width: W, height: H, bits: figure.bits.slice() };
|
|
355
|
+
for (const c of components(ink)) {
|
|
356
|
+
const b = maskBox(c)!;
|
|
357
|
+
const near = b[0] < x1 + reach && b[2] > x0 - reach && b[1] < y1 + reach && b[3] > y0 - reach;
|
|
358
|
+
// Not the picture's frame or a strip along its edge: that is the painting, not the figure.
|
|
359
|
+
const edge = b[0] <= 1 || b[1] <= 1 || b[2] >= W - 1 || b[3] >= H - 1;
|
|
360
|
+
if (near && !edge && maskArea(c) >= 0.04 * area && !standsIn(c, maskArea(c), area, box)) c.bits.forEach((v, i) => v && (out.bits[i] = 1));
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The mask with every gap it closes round (not reaching the picture's edge) filled. */
|
|
366
|
+
export function fillHoles(m: Mask): Mask {
|
|
367
|
+
const { width: W, height: H } = m;
|
|
368
|
+
const outside = new Uint8Array(W * H);
|
|
369
|
+
const stack: number[] = [];
|
|
370
|
+
for (let x = 0; x < W; x++) for (const y of [0, H - 1]) if (!m.bits[y * W + x]) stack.push(y * W + x);
|
|
371
|
+
for (let y = 0; y < H; y++) for (const x of [0, W - 1]) if (!m.bits[y * W + x]) stack.push(y * W + x);
|
|
372
|
+
while (stack.length) {
|
|
373
|
+
const i = stack.pop()!;
|
|
374
|
+
if (outside[i] || m.bits[i]) continue;
|
|
375
|
+
outside[i] = 1;
|
|
376
|
+
const x = i % W;
|
|
377
|
+
if (x > 0) stack.push(i - 1);
|
|
378
|
+
if (x < W - 1) stack.push(i + 1);
|
|
379
|
+
if (i >= W) stack.push(i - W);
|
|
380
|
+
if (i < W * (H - 1)) stack.push(i + W);
|
|
381
|
+
}
|
|
382
|
+
const out = emptyMask(W, H);
|
|
383
|
+
for (let i = 0; i < W * H; i++) out.bits[i] = outside[i] ? 0 : 1;
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const BONES: PartName[] = ['torso', 'nearThigh', 'nearShin', 'nearBoot', 'farThigh', 'farShin', 'farBoot', 'nearArm', 'farArm'];
|
|
388
|
+
|
|
389
|
+
/** Box-blurs each bone's weight over the mesh (three passes of radius r), among the vertices the figure uses. */
|
|
390
|
+
function smoothWeights(weights: Weights[], gx: number, gy: number, used: Set<number>, r: number) {
|
|
391
|
+
const n = BONES.length;
|
|
392
|
+
let w = new Float32Array(gx * gy * n);
|
|
393
|
+
weights.forEach((list, v) => { for (const [bone, k] of list) w[v * n + BONES.indexOf(bone)] += k; });
|
|
394
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
395
|
+
for (const horizontal of [true, false]) {
|
|
396
|
+
const next = new Float32Array(w);
|
|
397
|
+
for (const v of used) {
|
|
398
|
+
const i = v % gx;
|
|
399
|
+
const j = Math.floor(v / gx);
|
|
400
|
+
const acc = new Float32Array(n);
|
|
401
|
+
let count = 0;
|
|
402
|
+
for (let d = -r; d <= r; d++) {
|
|
403
|
+
const ii = horizontal ? i + d : i;
|
|
404
|
+
const jj = horizontal ? j : j + d;
|
|
405
|
+
if (ii < 0 || jj < 0 || ii >= gx || jj >= gy) continue;
|
|
406
|
+
const u = jj * gx + ii;
|
|
407
|
+
if (!used.has(u)) continue;
|
|
408
|
+
for (let b = 0; b < n; b++) acc[b] += w[u * n + b];
|
|
409
|
+
count++;
|
|
410
|
+
}
|
|
411
|
+
for (let b = 0; b < n; b++) next[v * n + b] = acc[b] / count;
|
|
412
|
+
}
|
|
413
|
+
w = next;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const v of used) weights[v] = BONES.map((bone, b): [PartName, number] => [bone, w[v * n + b]]).filter(([, k]) => k > 1e-4);
|
|
417
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// weather: Spark reads what falls on a place (rain, snow, nothing) and which edges drip; SAM finds those edges
|
|
2
|
+
// and the water and puddles drops ring on. The page animates all of it (web/weather.ts), so the painting
|
|
3
|
+
// keeps only the marks of the weather (wet sheen, puddles) and never a frozen streak.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { maskArea, type Mask } from '../image/mask.ts';
|
|
6
|
+
import { bottomRows, components, dilate, subtract, unionAll } from '../image/raster.ts';
|
|
7
|
+
import type { Memo } from '../meta/memo.ts';
|
|
8
|
+
import { samFind } from '../meta/sam.ts';
|
|
9
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
10
|
+
import type { Geometry } from '../play/geometry.ts';
|
|
11
|
+
import { DEPTH_SCALE, type Depth } from '../play/shadow.ts';
|
|
12
|
+
import type { Weather, WeatherWish } from '../play/weather.ts';
|
|
13
|
+
import type { Place } from '../world/schema.ts';
|
|
14
|
+
|
|
15
|
+
const Read = z.object({
|
|
16
|
+
fall: z.enum(['none', 'rain', 'snow']),
|
|
17
|
+
strength: z.number().min(0).max(1).default(0.6),
|
|
18
|
+
wind: z.number().min(-1).max(1).default(0),
|
|
19
|
+
fog: z.number().min(0).max(1).default(0),
|
|
20
|
+
drips: z.array(z.object({ phrase: z.string().min(2) })).max(4).default([]),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Drops along an edge at most this far apart, and at most this many per edge. */
|
|
24
|
+
const DRIP_EVERY = 22;
|
|
25
|
+
const DRIPS_PER_EDGE = 10;
|
|
26
|
+
/** A puddle larger than this share of the frame is SAM taking a whole wet floor. */
|
|
27
|
+
const PUDDLE_MAX = 0.08;
|
|
28
|
+
|
|
29
|
+
export type WeatherOut = { weather: Omit<Weather, 'ripples'>; ripples?: Mask };
|
|
30
|
+
|
|
31
|
+
export async function readWeather(memo: Memo, place: Place, painting: Buffer, g: Geometry, depth: Depth | undefined, dry: Mask, wish: WeatherWish): Promise<WeatherOut> {
|
|
32
|
+
const prompt = `This is a painted background of a 2D point-and-click adventure game. The game animates the weather over it.
|
|
33
|
+
Place: ${place.shows}
|
|
34
|
+
Mood: ${place.mood}${wish.hint ? `\nThe author says: ${wish.hint}` : ''}
|
|
35
|
+
|
|
36
|
+
1. "fall": what falls from the sky in this scene right now: "rain", "snow" or "none". Say rain or snow only when the mood or the picture shows it falling now (wet ground alone after rain is "none"). Indoors, in a cave or under a roof it is "none".
|
|
37
|
+
2. "strength" 0..1: 0.3 drizzle, 0.6 steady rain, 1 a downpour (the same for snow).
|
|
38
|
+
3. "wind" -1..1: how much the fall slants, positive toward the right, 0 straight down.
|
|
39
|
+
4. "drips": up to 4 edges in the picture that water drips from in this weather: roof eaves, a gutter, an awning, a dripping pipe, a wet cave ceiling, the underside of a bridge. Only edges you can see. Each as a short noun phrase an image segmentation model can find ("slate roof", "wooden awning", "rusty pipe"). None when nothing drips.
|
|
40
|
+
5. "fog" 0..1: how much mist or fog drifts through the scene, as the picture and the mood show it: 0 clear air, 0.3 a haze over water or a valley, 0.7 thick fog. Indoors 0 unless steam or smoke fills the room.
|
|
41
|
+
Answer with JSON only: {"fall": "rain", "strength": 0.6, "wind": 0.1, "fog": 0, "drips": [{"phrase": "..."}]}`;
|
|
42
|
+
const read = Read.parse(await askSparkJson(memo, { schema: Read, label: `weather:${place.key}`, prompt, images: [painting] }));
|
|
43
|
+
const fall = wish.fall === 'auto' ? read.fall : wish.fall;
|
|
44
|
+
const strength = wish.strength ?? (fall === 'none' ? 0 : read.strength);
|
|
45
|
+
const [W, H] = [g.frame.width, g.frame.height];
|
|
46
|
+
|
|
47
|
+
const drips: Weather['drips'] = [];
|
|
48
|
+
for (const [i, d] of read.drips.entries()) {
|
|
49
|
+
const hits = await samFind(memo, { label: `drip:${place.key}/${i}`, image: painting, prompt: d.phrase });
|
|
50
|
+
const mask = unionAll(W, H, hits.map((h) => h.mask).filter((m) => maskArea(m) < 0.3 * W * H));
|
|
51
|
+
const edge = dripEdge(mask, g, depth);
|
|
52
|
+
if (edge.from.length) drips.push({ phrase: d.phrase, ...edge });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Drops ring on water and puddles; rain only (snow settles).
|
|
56
|
+
let ripples: Mask | undefined;
|
|
57
|
+
if (fall === 'rain') {
|
|
58
|
+
const water = unionAll(W, H, (await samFind(memo, { label: `rain-water:${place.key}`, image: painting, prompt: 'water' })).map((h) => h.mask));
|
|
59
|
+
const puddles = (await samFind(memo, { label: `rain-puddle:${place.key}`, image: painting, prompt: 'puddle' })).map((h) => h.mask).filter((m) => maskArea(m) < PUDDLE_MAX * W * H);
|
|
60
|
+
// SAM's water also takes wet planks and shiny cobbles: those are the floors and the things, where rings would float.
|
|
61
|
+
const m = unionAll(W, H, [subtract(water, dry), ...puddles]);
|
|
62
|
+
if (maskArea(m) > 400) ripples = m;
|
|
63
|
+
}
|
|
64
|
+
const fog = wish.fog ?? read.fog;
|
|
65
|
+
// Mist drifts with the wind; with nothing falling the wind Spark read still moves it.
|
|
66
|
+
return { weather: { fall, strength, wind: fall === 'none' && !fog ? 0 : read.wind, fog, drips }, ripples };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where an edge drips: the lowest pixel of the mask per column (a roof's eave, a pipe's underside), sampled
|
|
71
|
+
* along it. Each drop lands on the floor row as far away as the edge (the depth map), else on the first
|
|
72
|
+
* walkable cell below it, else a little under it.
|
|
73
|
+
*/
|
|
74
|
+
export function dripEdge(mask: Mask, g: Geometry, depth?: Depth): { from: Array<[number, number]>; to: number[] } {
|
|
75
|
+
const main = components(mask).sort((a, b) => maskArea(b) - maskArea(a)).slice(0, 2);
|
|
76
|
+
if (!main.length) return { from: [], to: [] };
|
|
77
|
+
const bottom = bottomRows(unionAll(mask.width, mask.height, main));
|
|
78
|
+
const cols: number[] = [];
|
|
79
|
+
for (let x = 0; x < bottom.length; x += DRIP_EVERY) if (bottom[x] >= 0 && bottom[x] < g.frame.height - 4) cols.push(x);
|
|
80
|
+
const pick = cols.length > DRIPS_PER_EDGE ? Array.from({ length: DRIPS_PER_EDGE }, (_, k) => cols[Math.round((k * (cols.length - 1)) / (DRIPS_PER_EDGE - 1))]) : cols;
|
|
81
|
+
const from: Array<[number, number]> = [];
|
|
82
|
+
const to: number[] = [];
|
|
83
|
+
for (const x of pick) {
|
|
84
|
+
const y = bottom[x] + 1;
|
|
85
|
+
const land = landing(g, depth, x, y);
|
|
86
|
+
if (land - y < 6) continue;
|
|
87
|
+
from.push([x, y]);
|
|
88
|
+
to.push(land);
|
|
89
|
+
}
|
|
90
|
+
return { from, to };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function landing(g: Geometry, depth: Depth | undefined, x: number, y: number): number {
|
|
94
|
+
const H = g.frame.height;
|
|
95
|
+
const col = Math.floor(x / g.cell);
|
|
96
|
+
// The floor under the edge: the first walkable cell below it, and how far that floor runs toward the viewer.
|
|
97
|
+
// An eave over a porch drips at the porch's front edge, not into the yard beyond, whatever the depth says.
|
|
98
|
+
const floorOf = (r: number) => (g.zone ? g.zone[r]?.[col] : g.walk[r]?.[col] === '#' ? '#' : '.');
|
|
99
|
+
let first = -1;
|
|
100
|
+
for (let r = Math.floor(y / g.cell) + 1; r < g.walk.length; r++) if (g.walk[r][col] === '#') { first = r; break; }
|
|
101
|
+
let front = first;
|
|
102
|
+
if (first >= 0) while (front + 1 < g.walk.length && floorOf(front + 1) === floorOf(first) && floorOf(first) !== '.') front++;
|
|
103
|
+
const runEnd = first >= 0 ? Math.min(H - 1, (front + 1) * g.cell) : H - 1;
|
|
104
|
+
if (depth) {
|
|
105
|
+
const c = Math.min(depth.w - 1, Math.floor(x / DEPTH_SCALE));
|
|
106
|
+
const r = Math.min(depth.h - 1, Math.floor(y / DEPTH_SCALE));
|
|
107
|
+
const rows = depth.rows[r * depth.w + c];
|
|
108
|
+
const at = g.scale.horizonY + rows;
|
|
109
|
+
if (rows > 0 && at > y + 6) return Math.min(runEnd, at);
|
|
110
|
+
}
|
|
111
|
+
if (first >= 0) return Math.min(H - 1, (first + 0.5) * g.cell);
|
|
112
|
+
return Math.min(H - 1, y + 60);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The floors and standing things, grown a little: no water there. */
|
|
116
|
+
export function dryOf(width: number, height: number, masks: Mask[]): Mask {
|
|
117
|
+
return dilate(unionAll(width, height, masks), 6);
|
|
118
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The image safety filter refuses a painting prompt on words, not pictures: "bare trees", seats "hanging on chains"
|
|
2
|
+
// in an empty playground read as harm to people. The words that trip it come from Spark's brief, not the person,
|
|
3
|
+
// so paint rewords the place (keeping what it shows) and paints again; paint_words lets the person or the agent
|
|
4
|
+
// set the wording by hand. Either is kept in corrections.json `words`, read by every later paint.
|
|
5
|
+
import type { Memo } from '../meta/memo.ts';
|
|
6
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
7
|
+
import type { Place } from '../world/schema.ts';
|
|
8
|
+
import type { Brief } from './brief.ts';
|
|
9
|
+
import type { Corrections } from './corrections.ts';
|
|
10
|
+
import type { Layout } from './layout.ts';
|
|
11
|
+
|
|
12
|
+
export type Words = NonNullable<Corrections['words']>;
|
|
13
|
+
|
|
14
|
+
/** Said in every prompt that writes words a painter will get. */
|
|
15
|
+
export const SAFE_WORDS = 'The painter runs an image safety filter that refuses whole prompts on single words, even in a harmless scene. '
|
|
16
|
+
+ 'Never use words that can read as harm to or exposure of people: say leafless (not bare or naked), seats that rest or sit (not hang, dangle, '
|
|
17
|
+
+ 'swing from chains or ropes), no blood, wounds, bodies, corpses, graves, nooses, weapons, guns, killing, death, and no children or kids '
|
|
18
|
+
+ '(say small visitors, or leave people out).';
|
|
19
|
+
|
|
20
|
+
/** The filter's refusal (Meta: 400 content_policy_violation), as opposed to any other failure. */
|
|
21
|
+
export const refusedByFilter = (error: unknown) => error instanceof Error && /content_policy_violation|content management policy|safety (system|filter)/i.test(error.message);
|
|
22
|
+
|
|
23
|
+
/** The place as the painter is told it: the wording's scene and mood, and its looks for things in place of world.json's. */
|
|
24
|
+
export function worded(place: Place, words?: Words): Place {
|
|
25
|
+
if (!words) return place;
|
|
26
|
+
return {
|
|
27
|
+
...place,
|
|
28
|
+
...(words.shows && { shows: words.shows }),
|
|
29
|
+
...(words.mood && { mood: words.mood }),
|
|
30
|
+
things: place.things.map((t) => (words.looks[t.key] && t.role !== 'npc' ? { ...t, looks: words.looks[t.key] } : t)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Spark's brief with the wording's looks in place of its own, kept word for word. */
|
|
35
|
+
export const withWords = (brief: Brief, words?: Words): Brief => (words ? { ...brief, looks: { ...brief.looks, ...words.looks } } : brief);
|
|
36
|
+
|
|
37
|
+
/** Keys a wording may give a look for: the place's things (not NPCs, who are not painted) and floors. */
|
|
38
|
+
export const wordKeys = (place: Place, layout: Pick<Layout, 'floors'>) => [...place.things.filter((t) => t.role !== 'npc').map((t) => t.key), ...layout.floors.map((f) => f.key)];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Spark rewords a place the filter refused, keeping every subject what it is. `refusals` counts earlier rewords,
|
|
42
|
+
* so a reword the filter refused again asks anew (the memo keys on the prompt) instead of replaying itself.
|
|
43
|
+
*/
|
|
44
|
+
export async function reword(memo: Memo, place: Place, layout: Layout, brief: Brief, refusals: number): Promise<Words> {
|
|
45
|
+
const keys = wordKeys(place, layout);
|
|
46
|
+
const prompt = `An image model's safety filter refused to paint this background of a 2D point-and-click adventure${refusals ? ` (${refusals + 1} times now, also after rewording)` : ''}. Nothing in it is harmful: some word read wrongly. Reword it so the same scene is painted.
|
|
47
|
+
|
|
48
|
+
Place: ${place.shows}
|
|
49
|
+
Mood: ${place.mood}
|
|
50
|
+
${keys.map((k) => `- ${k}: ${brief.looks[k] ?? ''}`).join('\n')}
|
|
51
|
+
|
|
52
|
+
${SAFE_WORDS}
|
|
53
|
+
Keep every subject the same thing in the same state (an empty swing stays an empty swing: say its seat rests low under its frame). Write calm, plain words; leave out anything that only adds drama. One sentence per subject, each at least ten letters.
|
|
54
|
+
|
|
55
|
+
Reply with JSON only: {"shows": "...", "mood": "...", "looks": {${keys.map((k) => `"${k}": "..."`).join(', ')}}}`;
|
|
56
|
+
const answer = await askSparkJson<{ shows?: string; mood?: string; looks?: Record<string, string> }>(memo, { label: `reword:${place.key}`, prompt });
|
|
57
|
+
const looks = Object.fromEntries(keys.filter((k) => typeof answer.looks?.[k] === 'string' && answer.looks[k].length >= 10).map((k) => [k, answer.looks![k]]));
|
|
58
|
+
return {
|
|
59
|
+
...(typeof answer.shows === 'string' && answer.shows.length >= 10 && { shows: answer.shows }),
|
|
60
|
+
...(typeof answer.mood === 'string' && answer.mood.length >= 2 && { mood: answer.mood }),
|
|
61
|
+
looks,
|
|
62
|
+
by: 'filter',
|
|
63
|
+
tries: refusals + 1,
|
|
64
|
+
};
|
|
65
|
+
}
|