@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,216 @@
|
|
|
1
|
+
// layout.json: where things stand in a place, as a block-in for the painter.
|
|
2
|
+
// Spark lays it out; the gate checks it before any paint is spent on it.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { components, dilate, intersect, polygonMask, rectMask, subtract, unionAll } from '../image/raster.ts';
|
|
7
|
+
import { maskArea } from '../image/mask.ts';
|
|
8
|
+
import { inputHash, type Memo } from '../meta/memo.ts';
|
|
9
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
10
|
+
import type { Place, World } from '../world/schema.ts';
|
|
11
|
+
|
|
12
|
+
export const FRAME = { width: 1200, height: 896 } as const;
|
|
13
|
+
|
|
14
|
+
const Colour = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
|
15
|
+
const XY = z.tuple([z.number(), z.number()]);
|
|
16
|
+
|
|
17
|
+
/** The silhouette a thing gets in the block-in: enough for Muse to keep it where it is and the size it is. */
|
|
18
|
+
export const SHAPES = ['block', 'low', 'post', 'tree', 'arch', 'round', 'steps', 'flat'] as const;
|
|
19
|
+
export type Shape = (typeof SHAPES)[number];
|
|
20
|
+
|
|
21
|
+
export const Layout = z.object({
|
|
22
|
+
horizon: z.number(),
|
|
23
|
+
sky: Colour,
|
|
24
|
+
ground: Colour,
|
|
25
|
+
floors: z.array(z.object({ key: z.string(), is: z.string(), colour: Colour, outline: z.array(XY).min(3) })).min(1).max(4),
|
|
26
|
+
things: z.array(z.object({
|
|
27
|
+
key: z.string(),
|
|
28
|
+
colour: Colour,
|
|
29
|
+
x: z.number().describe('centre'),
|
|
30
|
+
footY: z.number().describe('where it meets the floor'),
|
|
31
|
+
width: z.number(),
|
|
32
|
+
height: z.number(),
|
|
33
|
+
metres: z.number().positive(),
|
|
34
|
+
shape: z.enum(SHAPES).optional().catch(undefined),
|
|
35
|
+
})),
|
|
36
|
+
lights: z.array(z.object({ key: z.string(), x: z.number(), y: z.number(), colour: Colour })).max(6),
|
|
37
|
+
});
|
|
38
|
+
export type Layout = z.infer<typeof Layout>;
|
|
39
|
+
|
|
40
|
+
/** `reach` off for a photo: the room is what it is. */
|
|
41
|
+
export function layoutIssues(layout: Layout, place: Place, reach = true): string[] {
|
|
42
|
+
const { width: W, height: H } = FRAME;
|
|
43
|
+
const issues: string[] = [];
|
|
44
|
+
if (layout.horizon < 120 || layout.horizon > 520) issues.push(`horizon ${layout.horizon} should be between 120 and 520`);
|
|
45
|
+
layout.floors.forEach((floor, i) => {
|
|
46
|
+
if (floor.outline.some(([x, y]) => x < 0 || x > W || y < 0 || y > H)) issues.push(`floor ${floor.key}: keep the outline inside ${W}x${H}`);
|
|
47
|
+
if (Math.min(...floor.outline.map(([, y]) => y)) < layout.horizon) issues.push(`floor ${floor.key} rises above the horizon at ${layout.horizon}`);
|
|
48
|
+
if (i === 0 && Math.max(...floor.outline.map(([, y]) => y)) < H - 30) issues.push(`floor ${floor.key} is listed first, so it must reach the bottom edge (y >= ${H - 30})`);
|
|
49
|
+
});
|
|
50
|
+
if (reach) issues.push(...reachIssues(layout, place), ...closeUpIssues(layout));
|
|
51
|
+
for (const thing of place.things) if (!layout.things.some((t) => t.key === thing.key)) issues.push(`${thing.key} is missing`);
|
|
52
|
+
for (const t of layout.things) {
|
|
53
|
+
if (!place.things.some((thing) => thing.key === t.key)) issues.push(`${t.key} is not a thing of ${place.key}`);
|
|
54
|
+
if (t.width < 40 || t.height < 40) issues.push(`${t.key}: at least 40x40 px, so a player can click it`);
|
|
55
|
+
const inside = t.x + t.width / 3 > 0 && t.x - t.width / 3 < W && t.footY - t.height >= 0 && t.footY <= H;
|
|
56
|
+
if (!inside) issues.push(`${t.key}: keep the box mostly inside the frame`);
|
|
57
|
+
}
|
|
58
|
+
return issues;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Quarter-size floor masks: enough to tell which floors touch. */
|
|
62
|
+
const Q = 4;
|
|
63
|
+
function floorMasks(layout: Layout) {
|
|
64
|
+
const { width: W, height: H } = FRAME;
|
|
65
|
+
return layout.floors.map((f) => polygonMask(W / Q, H / Q, f.outline.map(([x, y]) => [x / Q, y / Q] as [number, number])));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The hero walks: every floor has to join the first one (touching it, or through floors that do), and every
|
|
70
|
+
* exit, item and person has to stand on a floor. A quay across a canal with the exit on it is a painting
|
|
71
|
+
* nobody can play (ootb-test canal-steps); a bridge or steps between them is a floor of its own.
|
|
72
|
+
*/
|
|
73
|
+
export function reachIssues(layout: Layout, place: Place): string[] {
|
|
74
|
+
const issues: string[] = [];
|
|
75
|
+
const masks = floorMasks(layout);
|
|
76
|
+
const near = masks.map((m) => dilate(m, 3));
|
|
77
|
+
const joined = new Set([0]);
|
|
78
|
+
for (let grew = true; grew;) {
|
|
79
|
+
grew = false;
|
|
80
|
+
masks.forEach((m, i) => {
|
|
81
|
+
if (joined.has(i) || ![...joined].some((j) => maskArea(intersect(near[j], m)) > 0)) return;
|
|
82
|
+
joined.add(i);
|
|
83
|
+
grew = true;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
layout.floors.forEach((f, i) => {
|
|
87
|
+
if (!joined.has(i)) issues.push(`floor ${f.key} doesn't touch ${layout.floors[0].key} or a floor that does: the hero can't get there (add the bridge, steps or path between them as a floor, or drop it)`);
|
|
88
|
+
});
|
|
89
|
+
const used = new Set(place.things.filter((t) => t.role !== 'prop').map((t) => t.key));
|
|
90
|
+
for (const t of layout.things) {
|
|
91
|
+
if (!used.has(t.key)) continue;
|
|
92
|
+
const x = Math.round(Math.min(FRAME.width - 1, Math.max(0, t.x)) / Q), y = Math.round(Math.min(FRAME.height - 1, Math.max(0, t.footY)) / Q);
|
|
93
|
+
const on = near.findIndex((m) => m.bits[Math.min(m.height - 1, y) * m.width + Math.min(m.width - 1, x)]);
|
|
94
|
+
if (on < 0) issues.push(`${t.key}: its foot (${Math.round(t.x)}, ${Math.round(t.footY)}) is on no floor; the hero must be able to walk up to it`);
|
|
95
|
+
else if (!joined.has(on)) issues.push(`${t.key} stands on ${layout.floors[on].key}, which the hero can't reach`);
|
|
96
|
+
}
|
|
97
|
+
if (issues.length === 0) issues.push(...blockedIssues(layout, place, masks));
|
|
98
|
+
return issues;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Floors that touch can still be cut apart by what stands on them: a wide market stall set right across
|
|
103
|
+
* the only path to the canal (night-market lantern-market) leaves the path behind it unreachable. Each
|
|
104
|
+
* standing thing's foot is in the way, as deep as derive will make it; exits (a path, stairs, a door)
|
|
105
|
+
* and people are not, and small things are stepped round.
|
|
106
|
+
*/
|
|
107
|
+
export function blockedIssues(layout: Layout, place: Place, masks = floorMasks(layout)): string[] {
|
|
108
|
+
const { width: W, height: H } = FRAME;
|
|
109
|
+
const passable = new Set(place.things.filter((t) => t.role === 'exit' || t.role === 'npc').map((t) => t.key));
|
|
110
|
+
// Low wide things (a bridge, a rug, still water) are walked on or over, not round.
|
|
111
|
+
const blocks = layout.things.filter((t) => !passable.has(t.key) && t.metres >= 0.5 && t.height >= 0.5 * t.width).map((t) => {
|
|
112
|
+
const depth = Math.max(12, 0.35 * t.width);
|
|
113
|
+
return { key: t.key, mask: rectMask(W / Q, H / Q, [(t.x - t.width / 2) / Q, (t.footY - depth) / Q, (t.x + t.width / 2) / Q, t.footY / Q]) };
|
|
114
|
+
});
|
|
115
|
+
const walk = subtract(unionAll(W / Q, H / Q, masks), unionAll(W / Q, H / Q, blocks.map((b) => b.mask)));
|
|
116
|
+
const parts = components(walk);
|
|
117
|
+
const main = parts.reduce((best, c) => (maskArea(intersect(c, masks[0])) > maskArea(intersect(best, masks[0])) ? c : best), parts[0]);
|
|
118
|
+
if (!main) return [];
|
|
119
|
+
const reached = dilate(main, 2);
|
|
120
|
+
const issues: string[] = [];
|
|
121
|
+
layout.floors.forEach((f, i) => {
|
|
122
|
+
if (i === 0 || maskArea(intersect(reached, subtract(masks[i], unionAll(W / Q, H / Q, blocks.map((b) => b.mask))))) > 0) return;
|
|
123
|
+
const across = blocks.filter((b) => maskArea(intersect(b.mask, dilate(masks[i], 2))) > 0).map((b) => b.key);
|
|
124
|
+
issues.push(`floor ${f.key} is cut off by ${across.join(', ') || 'things'} standing across the way to it: move ${across.length > 1 ? 'them' : 'it'} aside or widen the floor so the hero can walk round`);
|
|
125
|
+
});
|
|
126
|
+
const used = new Set(place.things.filter((t) => t.role !== 'prop').map((t) => t.key));
|
|
127
|
+
for (const t of layout.things) {
|
|
128
|
+
if (!used.has(t.key)) continue;
|
|
129
|
+
const x = Math.round(Math.min(W - 1, Math.max(0, t.x)) / Q), y = Math.round(Math.min(H - 1, Math.max(0, t.footY)) / Q);
|
|
130
|
+
// A thing on a table is reached at the table.
|
|
131
|
+
const onTop = blocks.some((b) => b.key !== t.key && dilate(b.mask, 2).bits[y * b.mask.width + x]);
|
|
132
|
+
if (!onTop && masks.some((m) => m.bits[y * m.width + x]) && !dilate(reached, 4).bits[y * reached.width + x]) issues.push(`${t.key}: things stand between the hero and it; leave a way to walk up to it`);
|
|
133
|
+
}
|
|
134
|
+
return issues;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The tallest a person may stand at the bottom of the picture, as the layout's things imply it. */
|
|
138
|
+
export const CLOSE_UP_MAX = 0.6;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A camera too close: the things' sizes say how tall a 1.62 m person is at each depth, and at the bottom edge
|
|
142
|
+
* that must leave the hero room to walk (a bakery laid out at 610 px came out 940 px painted).
|
|
143
|
+
*/
|
|
144
|
+
export function closeUpIssues(layout: Layout): string[] {
|
|
145
|
+
const { height: H } = FRAME;
|
|
146
|
+
const implied = layout.things
|
|
147
|
+
.filter((t) => t.metres >= 0.5 && t.footY - layout.horizon > 40)
|
|
148
|
+
.map((t) => ((t.height * 1.62) / t.metres / (t.footY - layout.horizon)) * (H - layout.horizon))
|
|
149
|
+
.sort((a, b) => a - b);
|
|
150
|
+
if (implied.length < 2) return [];
|
|
151
|
+
const person = implied[Math.floor(implied.length / 2)];
|
|
152
|
+
return person > CLOSE_UP_MAX * H
|
|
153
|
+
? [`the camera is too close: by the things' sizes a person at the bottom edge would be ${Math.round(person)} px tall (keep it under ${Math.round(CLOSE_UP_MAX * H)}): reduce height relative to metres, move feet toward the bottom edge, or lower the horizon y coordinate`]
|
|
154
|
+
: [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function brief(world: World, place: Place): string {
|
|
158
|
+
const { width: W, height: H } = FRAME;
|
|
159
|
+
return `Lay out one place of a 2D point-and-click adventure as a block-in for a painter. The frame is ${W}x${H} px, x to the right, y down; the camera looks a little down at the place, like at a stage.
|
|
160
|
+
|
|
161
|
+
Style: ${world.style}
|
|
162
|
+
Place: ${place.shows}
|
|
163
|
+
Mood: ${place.mood}
|
|
164
|
+
Things (use these keys): ${place.things.map((t) => `${t.key} (${t.role}: ${t.looks})`).join('; ')}
|
|
165
|
+
|
|
166
|
+
Give:
|
|
167
|
+
- horizon: y of the horizon, or of where the back wall meets the floor (120 to 520)
|
|
168
|
+
- sky and ground: the main colour above and below it
|
|
169
|
+
- floors: 1 to 4 walkable areas below the horizon as outlines, nearest first; the first reaches the bottom edge; every other floor touches one listed before it (a bridge, steps or a path across water or a drop is a floor too), so the hero can walk everywhere; leave room to walk around things
|
|
170
|
+
- things: every thing as a box standing on the floor: x (centre), footY (where it touches the floor), width, height in px, metres (real height), colour (main colour), shape (its silhouette: block = a box, stall, cupboard; low = bench, table, boat, cart, bed; post = lamp, pole, sign, column; tree; arch = door, gate, archway, window, tunnel; round = barrel, pot, rock, bush, well; steps = stairs or steps; flat = lies on the floor: rug, water, tracks); nearer things sit lower and are bigger; exits sit at the edge of a floor; every exit, item and person stands on a floor; leave a way round every big thing, and never stand one across the only way to a floor
|
|
171
|
+
- lights: up to 6 visible sources (lamps, fires, windows) with x, y, colour
|
|
172
|
+
|
|
173
|
+
Reply with JSON only:
|
|
174
|
+
{"horizon": 0, "sky": "#rrggbb", "ground": "#rrggbb",
|
|
175
|
+
"floors": [{"key": "...", "is": "what the floor is", "colour": "#rrggbb", "outline": [[x, y], ...]}],
|
|
176
|
+
"things": [{"key": "...", "colour": "#rrggbb", "x": 0, "footY": 0, "width": 0, "height": 0, "metres": 1.0, "shape": "block"}],
|
|
177
|
+
"lights": [{"key": "...", "x": 0, "y": 0, "colour": "#rrggbb"}]}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Small positive boxes can be enlarged without moving their feet; every gate still runs afterwards. */
|
|
181
|
+
export function repairSmallBoxes(layout: Layout): Layout {
|
|
182
|
+
return { ...layout, things: layout.things.map((t) => ({ ...t,
|
|
183
|
+
width: t.width > 0 ? Math.max(40, t.width) : t.width,
|
|
184
|
+
height: t.height > 0 ? Math.max(40, t.height) : t.height,
|
|
185
|
+
})) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** With a photo, measure existing positions without resizing them. */
|
|
189
|
+
export async function layOut(memo: Memo, world: World, place: Place, photo?: Buffer, guidance?: string, concept?: Buffer): Promise<Layout> {
|
|
190
|
+
const ask = () => (photo ? `${brief(world, place)}\n\nThe place already exists: it is the attached photo, ${FRAME.width}x${FRAME.height} px. Do not place anything; measure where each thing, floor and light is in it.` : brief(world, place));
|
|
191
|
+
const base = ask() + (!photo && concept ? '\n\nThe attached image is the creator-approved concept for this place. Start from its camera and relative arrangement of the listed things, scaled to the output frame. Preserve the recognizable composition where possible, but adapt positions, sizes and floor connections to satisfy every walkability rule above. This is a concept, not a fixed photograph; do not copy inaccessible areas or invent extra things.' : '') + (guidance ? `\n\nLayout repair guidance: ${guidance}` : '');
|
|
192
|
+
// Keep paid replies. An exhausted batch becomes feedback for the next batch, never an identical replay.
|
|
193
|
+
const stateFile = path.join(memo.dir, 'layout-retries', `${inputHash(base, photo ?? '', ...(!photo && concept ? [concept] : []))}.json`);
|
|
194
|
+
const saved = z.object({ round: z.number().int().nonnegative(), answer: z.unknown(), issues: z.array(z.string()) });
|
|
195
|
+
let previous: z.infer<typeof saved> | undefined;
|
|
196
|
+
if (fs.existsSync(stateFile)) previous = saved.parse(JSON.parse(fs.readFileSync(stateFile, 'utf8')));
|
|
197
|
+
const round = (previous?.round ?? 0) + 1;
|
|
198
|
+
const feedback = (answer: unknown, issues: string[], attempt: number) => `${base}\n\nRepair batch ${round}, attempt ${attempt}. Your last answer was:\n${JSON.stringify(answer)}\n\nFix every one of these and reply with the whole JSON again:\n- ${issues.join('\n- ')}`;
|
|
199
|
+
let prompt = previous ? feedback(previous.answer, previous.issues, 1) : base;
|
|
200
|
+
let last: { answer: unknown; issues: string[] } | undefined;
|
|
201
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
202
|
+
const answer = await askSparkJson(memo, { label: `layout:${place.key}#${attempt}`, prompt, images: photo ? [photo] : concept ? [concept] : [] });
|
|
203
|
+
const raw = Layout.safeParse(answer);
|
|
204
|
+
const parsed = raw.success && !photo ? Layout.safeParse(repairSmallBoxes(raw.data)) : raw;
|
|
205
|
+
const issues = parsed.success ? layoutIssues(parsed.data, place, !photo) : parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
|
|
206
|
+
if (parsed.success && issues.length === 0) return parsed.data;
|
|
207
|
+
console.log(` ${place.key} layout try ${attempt}: ${issues.slice(0, 4).join('; ')}`);
|
|
208
|
+
last = { answer: parsed.success ? parsed.data : answer, issues };
|
|
209
|
+
prompt = feedback(last.answer, issues, attempt + 1);
|
|
210
|
+
}
|
|
211
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
212
|
+
const temp = `${stateFile}.${process.pid}.tmp`;
|
|
213
|
+
fs.writeFileSync(temp, JSON.stringify({ round, ...last }));
|
|
214
|
+
fs.renameSync(temp, stateFile);
|
|
215
|
+
throw new Error(`no valid layout for ${place.key} in 3 tries: ${last!.issues.join('; ')}`);
|
|
216
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Spark looks at the finished painting: where each thing really is, what kind of thing it is,
|
|
2
|
+
// and which grid cells are floor. Muse moves things away from the layout, so masks start from
|
|
3
|
+
// this, not from layout.json; the layout only fills in what Spark can't find.
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { emptyMask, type Mask } from '../image/mask.ts';
|
|
7
|
+
import type { Rect } from '../image/raster.ts';
|
|
8
|
+
import type { Memo } from '../meta/memo.ts';
|
|
9
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
10
|
+
import type { Place } from '../world/schema.ts';
|
|
11
|
+
import type { Brief } from './brief.ts';
|
|
12
|
+
import { FRAME, type Layout } from './layout.ts';
|
|
13
|
+
|
|
14
|
+
/** How a thing meets the floor, which decides walking and hiding around it. */
|
|
15
|
+
export const KINDS = ['standing', 'wall', 'on-top', 'walk-on', 'flat'] as const;
|
|
16
|
+
export type Kind = (typeof KINDS)[number];
|
|
17
|
+
|
|
18
|
+
const Spot = z.object({
|
|
19
|
+
box: z.tuple([z.number(), z.number(), z.number(), z.number()]).nullable(),
|
|
20
|
+
kind: z.enum(KINDS),
|
|
21
|
+
/** A two or three word noun phrase SAM can search for ("rowing boat"). */
|
|
22
|
+
phrase: z.string().min(2),
|
|
23
|
+
/** Another way to name it, tried when the first finds nothing ("street lantern" for "lamp post"). */
|
|
24
|
+
also: z.string().optional(),
|
|
25
|
+
});
|
|
26
|
+
export const Spots = z.record(z.string(), Spot);
|
|
27
|
+
export type Spots = z.infer<typeof Spots>;
|
|
28
|
+
|
|
29
|
+
export async function locateThings(memo: Memo, place: Place, brief: Brief, painting: Buffer): Promise<Spots> {
|
|
30
|
+
const painted = place.things.filter((t) => t.role !== 'npc');
|
|
31
|
+
const prompt = `This is a painted background for a 2D point-and-click adventure (${FRAME.width}x${FRAME.height} px, x right, y down). For each thing below give:
|
|
32
|
+
- box: a tight box [x0, y0, x1, y1] around it in the painting, or null if it is not painted
|
|
33
|
+
- kind: how it meets the floor:
|
|
34
|
+
standing = stands on the floor and people walk around it (barrel, table, tree, lamp post, tower, a boat on land or afloat on water)
|
|
35
|
+
wall = hangs on or is part of a wall or backdrop, people never pass in front of its base (door, window, sign, cliff)
|
|
36
|
+
on-top = rests on another thing (a pot on a table, a lamp on a crate)
|
|
37
|
+
walk-on = people walk on it (rug, path, bridge, stairs, jetty)
|
|
38
|
+
flat = lies flat and cannot be walked on (water, a pond, a hole); never something that rises above the ground or the water, like a boat
|
|
39
|
+
- phrase: two or three plain words naming what is painted, for an image search ("rowing boat", "oil can")
|
|
40
|
+
- also: another short name for it, in case the first finds nothing ("street lantern" for "lamp post")
|
|
41
|
+
|
|
42
|
+
Things:
|
|
43
|
+
${painted.map((t) => `- ${t.key}: ${brief.looks[t.key] ?? t.looks}`).join('\n')}
|
|
44
|
+
|
|
45
|
+
Reply with JSON only: {"<key>": {"box": [0, 0, 0, 0], "kind": "standing", "phrase": "...", "also": "..."}}`;
|
|
46
|
+
// A grid drawn over the painting did not make the boxes tighter (tried on foghorn), so the painting goes in plain.
|
|
47
|
+
const spots = Spots.parse(await askSparkJson(memo, { schema: Spots, label: `locate:${place.key}`, prompt, images: [painting] }));
|
|
48
|
+
const missing = painted.filter((t) => !spots[t.key]).map((t) => t.key);
|
|
49
|
+
if (missing.length) throw new Error(`locate ${place.key}: Spark skipped ${missing.join(', ')}`);
|
|
50
|
+
return spots;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The floor grid: 60 px cells, a letter per column from A, a number per row from 1.
|
|
54
|
+
export const GRID = 60;
|
|
55
|
+
const COLS = Math.ceil(FRAME.width / GRID);
|
|
56
|
+
const ROWS = Math.ceil(FRAME.height / GRID);
|
|
57
|
+
|
|
58
|
+
export const cellName = (col: number, row: number) => `${String.fromCharCode(65 + col)}${row + 1}`;
|
|
59
|
+
|
|
60
|
+
export function parseCell(name: string): [number, number] | undefined {
|
|
61
|
+
const m = /^([A-Z])(\d{1,2})$/.exec(name.trim().toUpperCase());
|
|
62
|
+
if (!m) return undefined;
|
|
63
|
+
const col = m[1].charCodeAt(0) - 65;
|
|
64
|
+
const row = Number(m[2]) - 1;
|
|
65
|
+
return col < COLS && row >= 0 && row < ROWS ? [col, row] : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The painting with the labelled grid drawn over it, for Spark to name cells on. */
|
|
69
|
+
export function gridImage(painting: Buffer): Promise<Buffer> {
|
|
70
|
+
const { width: W, height: H } = FRAME;
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
for (let c = 1; c < COLS; c++) lines.push(`<line x1="${c * GRID}" y1="0" x2="${c * GRID}" y2="${H}"/>`);
|
|
73
|
+
for (let r = 1; r < ROWS; r++) lines.push(`<line x1="0" y1="${r * GRID}" x2="${W}" y2="${r * GRID}"/>`);
|
|
74
|
+
const labels: string[] = [];
|
|
75
|
+
for (let c = 0; c < COLS; c++) {
|
|
76
|
+
for (let r = 0; r < ROWS; r++) labels.push(`<text x="${c * GRID + 3}" y="${r * GRID + 13}">${cellName(c, r)}</text>`);
|
|
77
|
+
}
|
|
78
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
|
|
79
|
+
<g stroke="#ffe600" stroke-opacity="0.8" stroke-width="1.5">${lines.join('')}</g>
|
|
80
|
+
<g font-family="sans-serif" font-size="12" font-weight="bold" fill="#ffe600" stroke="#000" stroke-width="2.5" paint-order="stroke">${labels.join('')}</g>
|
|
81
|
+
</svg>`;
|
|
82
|
+
return sharp(painting).resize(W, H, { fit: 'fill' }).composite([{ input: Buffer.from(svg) }]).png().toBuffer();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function cellsMask(cells: string[]): Mask {
|
|
86
|
+
const { width: W, height: H } = FRAME;
|
|
87
|
+
const m = emptyMask(W, H);
|
|
88
|
+
for (const name of cells) {
|
|
89
|
+
const at = parseCell(name);
|
|
90
|
+
if (!at) continue;
|
|
91
|
+
const [x0, y0] = [at[0] * GRID, at[1] * GRID];
|
|
92
|
+
for (let y = y0; y < Math.min(H, y0 + GRID); y++) m.bits.fill(1, y * W + x0, y * W + Math.min(W, x0 + GRID));
|
|
93
|
+
}
|
|
94
|
+
return m;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The box around the named cells, or undefined when none parse. */
|
|
98
|
+
export function cellsBox(cells: string[]): Rect | undefined {
|
|
99
|
+
const at = cells.map(parseCell).filter((c) => c !== undefined);
|
|
100
|
+
if (at.length === 0) return undefined;
|
|
101
|
+
const cols = at.map(([c]) => c);
|
|
102
|
+
const rows = at.map(([, r]) => r);
|
|
103
|
+
return [Math.min(...cols) * GRID, Math.min(...rows) * GRID, Math.min(FRAME.width, (Math.max(...cols) + 1) * GRID), Math.min(FRAME.height, (Math.max(...rows) + 1) * GRID)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const FloorCells = z.record(z.string(), z.array(z.string()));
|
|
107
|
+
|
|
108
|
+
/** Spark names the cells that are mostly each floor; floors it can't see get an empty list. */
|
|
109
|
+
export async function markFloors(memo: Memo, place: Place, layout: Layout, brief: Brief, painting: Buffer): Promise<Record<string, string[]>> {
|
|
110
|
+
const prompt = `This is a painted background for a 2D point-and-click adventure with a labelled grid over it: ${GRID} px cells, a letter per column (A at the left) and a number per row (1 at the top). For each floor below, list every cell that is mostly that floor: open ground where a person could stand. Leave out walls, sky, water, and cells mostly covered by things standing on the floor.
|
|
111
|
+
|
|
112
|
+
Floors:
|
|
113
|
+
${layout.floors.map((f) => `- ${f.key}: ${brief.looks[f.key] ?? f.is}`).join('\n')}
|
|
114
|
+
|
|
115
|
+
Reply with JSON only: {"<floor key>": ["C12", "D12", ...]}`;
|
|
116
|
+
const marked = FloorCells.parse(await askSparkJson(memo, { schema: FloorCells, label: `floors:${place.key}`, prompt, images: [await gridImage(painting)] }));
|
|
117
|
+
return Object.fromEntries(layout.floors.map((f) => [f.key, marked[f.key] ?? []]));
|
|
118
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The mask check: Spark looks at the painting with each thing's mask box drawn and numbered, and says for
|
|
2
|
+
// each box whether the thing is really in it. A mask and its box can agree and both be on the wrong thing
|
|
3
|
+
// (Spark boxed the stone next to the paper boat, SAM cut the stone): only a look at the picture catches that.
|
|
4
|
+
// One Spark call per place, remembered in the memo like every other.
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { Memo } from '../meta/memo.ts';
|
|
8
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
9
|
+
import type { Rect } from '../image/raster.ts';
|
|
10
|
+
import { svgLabel } from '../image/overlay.ts';
|
|
11
|
+
import { FRAME } from './layout.ts';
|
|
12
|
+
|
|
13
|
+
export const MaskCheck = z.object({
|
|
14
|
+
ok: z.boolean(),
|
|
15
|
+
/** What Spark sees in the box instead, when it is not the thing. */
|
|
16
|
+
seen: z.string().optional(),
|
|
17
|
+
/** Where the thing really is, when Spark can find it. */
|
|
18
|
+
box: z.tuple([z.number(), z.number(), z.number(), z.number()]).nullable().optional(),
|
|
19
|
+
});
|
|
20
|
+
export type MaskCheck = z.infer<typeof MaskCheck>;
|
|
21
|
+
|
|
22
|
+
const Reply = z.object({ boxes: z.record(z.string(), MaskCheck) });
|
|
23
|
+
|
|
24
|
+
export async function checkMasks(memo: Memo, place: string, painting: Buffer, things: Array<{ key: string; looks: string; box?: Rect }>): Promise<Record<string, MaskCheck>> {
|
|
25
|
+
const boxed = things.filter((t): t is typeof t & { box: Rect } => !!t.box);
|
|
26
|
+
if (boxed.length === 0) return {};
|
|
27
|
+
const svg = boxed.map((t, i) => {
|
|
28
|
+
const [x0, y0, x1, y1] = t.box;
|
|
29
|
+
return `<rect x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}" fill="none" stroke="#ff2bd6" stroke-width="3"/>${svgLabel(x0 + 3, Math.max(16, y0 - 4), String(i + 1), [255, 43, 214])}`;
|
|
30
|
+
}).join('');
|
|
31
|
+
const { width: W, height: H } = FRAME;
|
|
32
|
+
const image = await sharp(painting).resize(W, H, { fit: 'fill' })
|
|
33
|
+
.composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">${svg}</svg>`) }]).png().toBuffer();
|
|
34
|
+
const list = boxed.map((t, i) => `${i + 1}. ${t.looks}`).join('\n');
|
|
35
|
+
const prompt = `This painting (${W}x${H} px) has numbered pink boxes. Each box should hold the thing listed under its number:
|
|
36
|
+
${list}
|
|
37
|
+
|
|
38
|
+
For each number say whether that thing is inside its box. Judge the kind of thing, not its details: mossy stairs are "stone stairs", a dim lamp is "a glowing lantern". Say ok false only when the box clearly holds a different object (a stone where a boat should be) or nothing at all. Then say in a few words what the box holds, and give the box [x0, y0, x1, y1] in pixels where the thing really is, or null if you cannot find it anywhere.
|
|
39
|
+
|
|
40
|
+
Reply with JSON only: {"boxes": {"1": {"ok": true}, "2": {"ok": false, "seen": "a grey stone", "box": [0, 0, 0, 0]}}}`;
|
|
41
|
+
const reply = Reply.parse(await askSparkJson(memo, { schema: Reply, label: `maskcheck:${place}`, prompt, images: [image] }));
|
|
42
|
+
return Object.fromEntries(boxed.flatMap((t, i) => (reply.boxes[String(i + 1)] ? [[t.key, reply.boxes[String(i + 1)]]] : [])));
|
|
43
|
+
}
|