@gtanczyk/diorama 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// diorama play <slug>: serves the world to a browser page where you walk through its places.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { animationCurrent } from '../scene/animation-source.ts';
|
|
4
|
+
import { registeredFace } from '../scene/faces-data.ts';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { build } from 'esbuild';
|
|
9
|
+
import { Geometry } from '../play/geometry.ts';
|
|
10
|
+
import { previewGeometry } from '../play/preview.ts';
|
|
11
|
+
import type { Layers } from '../play/parallax.ts';
|
|
12
|
+
import { Rig } from '../play/rig.ts';
|
|
13
|
+
import { Reactions } from '../play/reactions.ts';
|
|
14
|
+
import { reactionInput } from './reactions.ts';
|
|
15
|
+
import { reactionSource } from '../scene/reactions.ts';
|
|
16
|
+
import { Weather } from '../play/weather.ts';
|
|
17
|
+
import { glowsOf, type Glow } from '../play/glow.ts';
|
|
18
|
+
import { readBodies } from '../scene/cast.ts';
|
|
19
|
+
import type { ModelInfo } from '../../web/hero3d.ts';
|
|
20
|
+
import { modelProblem } from '../model/glb.ts';
|
|
21
|
+
import type { SpriteInfo } from '../../web/main.ts';
|
|
22
|
+
import { soundData } from '../sound/data.ts';
|
|
23
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
24
|
+
import { ItemsInfo } from '../scene/icons.ts';
|
|
25
|
+
import { Layout } from '../scene/layout.ts';
|
|
26
|
+
import { Story } from '../story/schema.ts';
|
|
27
|
+
import { players } from '../world/drawings.ts';
|
|
28
|
+
import { WorldFiles } from '../world/files.ts';
|
|
29
|
+
|
|
30
|
+
/** layers.json as the backdrop step writes it: the same shape the page reads, its files relative to the place. */
|
|
31
|
+
type Flats = Layers;
|
|
32
|
+
|
|
33
|
+
export const WEB = fileURLToPath(new URL('../../web/', import.meta.url));
|
|
34
|
+
const TYPES: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.json': 'application/json', '.mp3': 'audio/mpeg' };
|
|
35
|
+
|
|
36
|
+
/** What the page loads: the world, and per place its geometry (measured, or a preview from the layout). */
|
|
37
|
+
export function worldData(files: WorldFiles) {
|
|
38
|
+
const world = files.world();
|
|
39
|
+
const places: Record<string, { geometry: Geometry | null; painting: string | null; backdrop?: string; layers?: Layers; weather?: Weather; reactions?: Reactions; glows?: Glow[]; issue?: string }> = {};
|
|
40
|
+
for (const place of world.places) {
|
|
41
|
+
const painted = ['painting.png', 'blockin.png'].find((f) => fs.existsSync(files.place(place.key, f)));
|
|
42
|
+
let geometry: Geometry | null = null;
|
|
43
|
+
let issue: string | undefined;
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(files.place(place.key, 'geometry.json'))) geometry = Geometry.parse(files.readJson(files.place(place.key, 'geometry.json')));
|
|
46
|
+
else if (fs.existsSync(files.place(place.key, 'layout.json'))) geometry = previewGeometry(Layout.parse(files.readJson(files.place(place.key, 'layout.json'))), place);
|
|
47
|
+
else issue = 'no layout.json or geometry.json yet';
|
|
48
|
+
} catch (error) {
|
|
49
|
+
issue = `geometry.json: ${error instanceof Error ? error.message.slice(0, 300) : error}`;
|
|
50
|
+
}
|
|
51
|
+
// backdrop.png: the painting without its standing things; with it the page draws the place as a parallax diorama.
|
|
52
|
+
// Not for a place painted from a photo: the film dissolves the photo into that exact frame, so it stays still.
|
|
53
|
+
const fromPhoto = fs.existsSync(files.place(place.key, 'photo.png'));
|
|
54
|
+
const backdrop = painted === 'painting.png' && !fromPhoto && fs.existsSync(files.place(place.key, 'backdrop.png')) ? `places/${place.key}/backdrop.png` : undefined;
|
|
55
|
+
// layers.json: the far view as flats (sky, then farthest to nearest), from the same backdrop step.
|
|
56
|
+
const layersFile = files.place(place.key, 'layers.json');
|
|
57
|
+
const flats = backdrop && fs.existsSync(layersFile) ? (files.readJson(layersFile) as Flats) : undefined;
|
|
58
|
+
const rel = (f: string) => `places/${place.key}/${f}`;
|
|
59
|
+
const layers: Layers | undefined = flats && {
|
|
60
|
+
sky: flats.sky ? rel(flats.sky) : null,
|
|
61
|
+
flats: flats.flats.map((f) => ({ file: rel(f.file), box: f.box })),
|
|
62
|
+
...(flats.water && { water: { tex: rel(flats.water.tex), mask: rel(flats.water.mask), box: flats.water.box, flow: flats.water.flow } }),
|
|
63
|
+
...(flats.mist && { mist: { ...flats.mist, tex: rel(flats.mist.tex), mask: rel(flats.mist.mask) } }),
|
|
64
|
+
// Smoke, rocking boats, waterfalls, animals and clouds: each a file or a few beside the place.
|
|
65
|
+
...(flats.smoke?.length && { smoke: flats.smoke.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
66
|
+
...(flats.floats?.length && { floats: flats.floats.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
67
|
+
...(flats.falls?.length && { falls: flats.falls.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
68
|
+
...(flats.critters?.length && { critters: flats.critters.map((f) => ({ ...f, file: rel(f.file) })) }),
|
|
69
|
+
...(flats.clouds && { clouds: { ...flats.clouds, clip: rel(flats.clouds.clip), ...(flats.clouds.keep && { keep: rel(flats.clouds.keep) }), bands: flats.clouds.bands.map((b) => ({ ...b, file: rel(b.file) })) } }),
|
|
70
|
+
};
|
|
71
|
+
// weather.json (diorama weather): what falls here and where water drips, for the page to animate.
|
|
72
|
+
const weatherFile = files.place(place.key, 'weather.json');
|
|
73
|
+
const read = painted === 'painting.png' && fs.existsSync(weatherFile) ? Weather.safeParse(files.readJson(weatherFile)) : undefined;
|
|
74
|
+
const weather = read?.success ? { ...read.data, ...(read.data.ripples && { ripples: rel(read.data.ripples) }) } : undefined;
|
|
75
|
+
// layout.lights: the painted lights, to breathe, flicker, twinkle or turn their beam (src/play/glow.ts).
|
|
76
|
+
const layoutFile = files.place(place.key, 'layout.json');
|
|
77
|
+
const lights = painted === 'painting.png' && fs.existsSync(layoutFile) ? Layout.safeParse(files.readJson(layoutFile)) : undefined;
|
|
78
|
+
// On the lights as painted when derive found them (geometry.glows); an older derive has only the planned points.
|
|
79
|
+
const paintedAt = geometry?.glows;
|
|
80
|
+
const sources = lights?.success ? (paintedAt ? lights.data.lights.flatMap((l) => { const p = paintedAt.find((g) => g.key === l.key); return p ? [{ ...l, x: p.x, y: p.y }] : []; }) : lights.data.lights) : [];
|
|
81
|
+
const glows = sources.length ? glowsOf(sources) : undefined;
|
|
82
|
+
let reactions: Reactions | undefined;
|
|
83
|
+
const reactionsFile = files.place(place.key, 'reactions.json');
|
|
84
|
+
if (fs.existsSync(reactionsFile)) {
|
|
85
|
+
try {
|
|
86
|
+
const rules = Reactions.parse(files.readJson(reactionsFile));
|
|
87
|
+
if (rules.source === reactionSource(reactionInput(files, place.key))) reactions = rules;
|
|
88
|
+
else console.warn(`${place.key}: reactions stale; rerun reactions`);
|
|
89
|
+
} catch (e) { console.warn(`${place.key}: reactions unavailable: ${e instanceof Error ? e.message : String(e)}`); }
|
|
90
|
+
}
|
|
91
|
+
places[place.key] = { geometry, painting: painted ? `places/${place.key}/${painted}` : null, ...(backdrop && { backdrop }), ...(layers && { layers }), ...(weather && { weather }), ...(reactions && { reactions }), ...(glows && { glows }), issue };
|
|
92
|
+
}
|
|
93
|
+
const castDir = files.path('cast');
|
|
94
|
+
// cast/figures.json (diorama cast): metres each one stands (a grown-up 1.62) and whether upright; one that
|
|
95
|
+
// isn't (a cat) is drawn as painted, whatever rig, model or sheets an older build left.
|
|
96
|
+
const bodies = readBodies(files, fs.existsSync);
|
|
97
|
+
const cast = Object.fromEntries((fs.existsSync(castDir) ? fs.readdirSync(castDir) : [])
|
|
98
|
+
.filter((f) => f.endsWith('.png') && !f.endsWith('-painting.png'))
|
|
99
|
+
.map((f) => {
|
|
100
|
+
const key = f.slice(0, -4);
|
|
101
|
+
// A walk that measured torn when it was made (rig.json check) is left out: the painted figure slides as one piece.
|
|
102
|
+
const rig = animationCurrent(files,key,'rig') && fs.existsSync(files.path('cast', key, 'rig.json')) ? Rig.parse(files.readJson(files.path('cast', key, 'rig.json'))) : undefined;
|
|
103
|
+
// cast/<key>/model/model.json (diorama model): a rigged 3D figure the page draws instead of the 2D rig.
|
|
104
|
+
// A flat one (a card TRELLIS made of a flat drawing, kept before the model step checked) is left out, and so
|
|
105
|
+
// are the sheets painted over its guides: the character walks with its 2D rig.
|
|
106
|
+
const modelOk = animationCurrent(files,key,'model') && fs.existsSync(files.path('cast', key, 'model', 'model.json')) && !modelProblem(files.path('cast', key, 'model'));
|
|
107
|
+
const model = modelOk ? { dir: `cast/${key}/model`, info: files.readJson(files.path('cast', key, 'model', 'model.json')) as ModelInfo } : undefined;
|
|
108
|
+
// cast/<key>/sprites/sprites.json (diorama sprites): painted walk sheets, preferred over the 3D figure.
|
|
109
|
+
const sprites = modelOk && animationCurrent(files,key,'sprites') && fs.existsSync(files.path('cast', key, 'sprites', 'sprites.json')) ? { dir: `cast/${key}/sprites`, info: files.readJson(files.path('cast', key, 'sprites', 'sprites.json')) as SpriteInfo } : undefined;
|
|
110
|
+
const face = registeredFace(files, key);
|
|
111
|
+
const body = bodies[key];
|
|
112
|
+
// Sheets rendered straight from the model's UV texture (model-*.png, no Muse paint) are a blurry stand-in
|
|
113
|
+
// with the rig's A-pose arms: a 2D walk bent from the character's own painting, when it passed its check,
|
|
114
|
+
// looks like the place it walks in and wins over them. Sheets Muse painted still win over the 2D walk.
|
|
115
|
+
const rendered = sprites && Object.values(sprites.info.facings ?? {}).every((f) => f.sheet.startsWith('model-'));
|
|
116
|
+
const ownWalk = rig?.sheet && rig.check?.ok !== false;
|
|
117
|
+
const sheets = rendered && ownWalk && process.env.DIORAMA_UV_SPRITES !== '1' ? undefined : sprites;
|
|
118
|
+
const walks = body?.upright !== false;
|
|
119
|
+
// A creature has no legs for a 2D rig, but its own 3D model (no skeleton, model.json creature) hops and squashes.
|
|
120
|
+
const creatureModel = model?.info.creature ? model : undefined;
|
|
121
|
+
// cast/<key>/cut.json (diorama cast, rig): the share of the cut-out's height the body takes when it holds something above it.
|
|
122
|
+
const cutAt = files.path('cast', key, 'cut.json');
|
|
123
|
+
const share = fs.existsSync(cutAt) ? Number((files.readJson(cutAt) as { share?: unknown }).share) : 1;
|
|
124
|
+
return [key, { image: `cast/${f}`, ...(share > 0 && share < 1 && { share }), ...(face && { face }), ...(body && { height: body.height }), ...(!walks && { upright: false }), ...(walks && rig && rig.check?.ok !== false ? { rig, parts: `cast/${key}` } : {}), ...(walks ? model && { model } : creatureModel && { model: creatureModel }), ...(walks && sheets && { sprites: sheets }) }];
|
|
125
|
+
}));
|
|
126
|
+
const story = fs.existsSync(files.path('story.json')) ? Story.parse(files.readJson(files.path('story.json'))) : null;
|
|
127
|
+
// changes/<i>.png per place: how story.changes[i] looks once it holds.
|
|
128
|
+
const changes: Record<string, string> = {};
|
|
129
|
+
// changes/<i>-shape.png: the changed thing's shape in it, which its hover outline follows while the change holds.
|
|
130
|
+
const changeShapes: Record<string, string> = {};
|
|
131
|
+
(story?.changes ?? []).forEach((c, i) => {
|
|
132
|
+
const file = `places/${c.place}/changes/${i}.png`;
|
|
133
|
+
if (fs.existsSync(files.path(file))) changes[i] = file;
|
|
134
|
+
const shape = `places/${c.place}/changes/${i}-shape.png`;
|
|
135
|
+
if (changes[i] && fs.existsSync(files.path(shape))) changeShapes[i] = shape;
|
|
136
|
+
});
|
|
137
|
+
// sound.json + sound/: what the world sounds like (diorama sound); absent, the page plays silent.
|
|
138
|
+
const sound = soundData(files);
|
|
139
|
+
// items/items.json (diorama icons): a painted icon per story item; without it the bag shows names.
|
|
140
|
+
const itemsFile = files.path('items', 'items.json');
|
|
141
|
+
const items = fs.existsSync(itemsFile)
|
|
142
|
+
? Object.fromEntries(Object.entries(ItemsInfo.parse(files.readJson(itemsFile))).filter(([, i]) => fs.existsSync(files.path(i.icon))).map(([k, i]) => [k, { icon: i.icon }]))
|
|
143
|
+
: undefined;
|
|
144
|
+
// The heroes of more gamepads (drawings marked player), in drawing order: pad 2 plays the first.
|
|
145
|
+
const others = players(files).map((p) => p.key).filter((k) => cast[k]);
|
|
146
|
+
return { slug: files.slug, world, places, cast, story, changes, changeShapes, ...(sound && { sound }), ...(items && { items }), ...(others.length && { players: others }) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function bundle(forShip = false): Promise<string> {
|
|
150
|
+
const out = await build({ entryPoints: [path.join(WEB, 'main.ts')], bundle: true, format: 'esm', write: false, target: 'es2022', ...(forShip ? { minify: true } : { sourcemap: 'inline' }) });
|
|
151
|
+
return out.outputFiles[0].text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Serves the page and the world's files on localhost; resolves once listening. */
|
|
155
|
+
export async function serveWorld(files: WorldFiles, port: number): Promise<http.Server> {
|
|
156
|
+
const server = http.createServer(async (req, res) => {
|
|
157
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
158
|
+
const send = (status: number, type: string, body: string | Buffer) => {
|
|
159
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
160
|
+
res.end(body);
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
if (url.pathname === '/') return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(WEB, 'index.html')));
|
|
164
|
+
if (url.pathname === '/prototype/conductor-lipsync') return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(WEB, '../demo/conductor-lipsync/index.html')));
|
|
165
|
+
if (url.pathname === '/main.js') return send(200, 'text/javascript; charset=utf-8', await bundle());
|
|
166
|
+
if (url.pathname === '/world-data.json') return send(200, 'application/json', JSON.stringify(worldData(files)));
|
|
167
|
+
if (url.pathname.startsWith('/files/')) {
|
|
168
|
+
const file = path.resolve(files.dir, decodeURIComponent(url.pathname.slice('/files/'.length)));
|
|
169
|
+
if (!file.startsWith(files.dir + path.sep) || !fs.existsSync(file)) return send(404, 'text/plain', 'not found');
|
|
170
|
+
return send(200, TYPES[path.extname(file)] ?? 'application/octet-stream', fs.readFileSync(file));
|
|
171
|
+
}
|
|
172
|
+
send(404, 'text/plain', 'not found');
|
|
173
|
+
} catch (error) {
|
|
174
|
+
send(500, 'text/plain', String(error instanceof Error ? error.stack : error));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
await new Promise<void>((resolve) => server.listen(port, resolve));
|
|
178
|
+
return server;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
defineCommand({
|
|
182
|
+
name: 'play',
|
|
183
|
+
usage: 'play <slug> serve the world at http://localhost:4173 to walk through it [--port N]',
|
|
184
|
+
async run(args) {
|
|
185
|
+
const port = Number(takeOption(args, 'port', '4173'));
|
|
186
|
+
const [slug] = args;
|
|
187
|
+
if (!slug) throw new Error('usage: diorama play <slug>');
|
|
188
|
+
const files = new WorldFiles(slug);
|
|
189
|
+
files.world();
|
|
190
|
+
|
|
191
|
+
await serveWorld(files, port);
|
|
192
|
+
console.log(`play ${slug}: http://localhost:${port} (g: grid and occluders, Ctrl+C to stop)`);
|
|
193
|
+
await new Promise(() => {});
|
|
194
|
+
},
|
|
195
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// diorama playthrough <slug> [ending...]: the bot plays the shortest route to each ending in the real page,
|
|
2
|
+
// fails if an ending is not reached, and records each run (playthrough/<ending>.webm) when --video is given,
|
|
3
|
+
// with the page's own sound laid under it (playthrough/<ending>.mp4) when the world has sound.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { Page } from 'playwright';
|
|
8
|
+
import { playRoute } from '../qa/route.ts';
|
|
9
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
10
|
+
import { checkStory } from '../story/check.ts';
|
|
11
|
+
import type { Action } from '../story/engine.ts';
|
|
12
|
+
import { WorldFiles } from '../world/files.ts';
|
|
13
|
+
import { chromium } from './explore.ts';
|
|
14
|
+
import { serveWorld } from './play.ts';
|
|
15
|
+
|
|
16
|
+
type Step = { use: string; verb?: string; with?: string } | { act: Action } | { wait: number };
|
|
17
|
+
|
|
18
|
+
/** A story action as what a player does on the page: click the thing, or pick an answer. */
|
|
19
|
+
export function toStep(action: Action): Step {
|
|
20
|
+
switch (action.kind) {
|
|
21
|
+
case 'go': return { use: action.via };
|
|
22
|
+
case 'talk': return { use: action.npc };
|
|
23
|
+
case 'do': return { use: action.thing, verb: action.verb, ...(action.with ? { with: action.with } : {}) };
|
|
24
|
+
default: return { act: action };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The run's video with the page's sound under it, as mp4; the sound started `offset` s into the video. */
|
|
29
|
+
function withSound(webm: string, audio: Buffer, offset: number): string {
|
|
30
|
+
const sound = webm.replace(/\.webm$/, '.sound.webm');
|
|
31
|
+
const mp4 = webm.replace(/\.webm$/, '.mp4');
|
|
32
|
+
fs.writeFileSync(sound, audio);
|
|
33
|
+
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', webm, '-itsoffset', offset.toFixed(3), '-i', sound, '-map', '0:v', '-map', '1:a',
|
|
34
|
+
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '20', '-c:a', 'aac', '-b:a', '192k', mp4]);
|
|
35
|
+
return mp4;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Run = { ending: string; reached: string | null; clicks: number; seconds: number; frames: number; video?: string; error?: string };
|
|
39
|
+
|
|
40
|
+
defineCommand({
|
|
41
|
+
name: 'playthrough',
|
|
42
|
+
usage: 'playthrough <slug> [ending...] play the route to each ending in the browser and check it is reached [--video] [--query "parallax=1&track=1&voices=npc"] [--lead S] [--port N]',
|
|
43
|
+
async run(args) {
|
|
44
|
+
const video = args.includes('--video');
|
|
45
|
+
const port = Number(takeOption(args, 'port', '4193'));
|
|
46
|
+
// Extra page options, e.g. parallax=1&track=1, so a recording looks like the game people play.
|
|
47
|
+
const query = takeOption(args, 'query', '');
|
|
48
|
+
// Seconds of the first place before the first click, so the intro line is heard in full.
|
|
49
|
+
const lead = Number(takeOption(args, 'lead', '2.5'));
|
|
50
|
+
const [slug, ...only] = args.filter((a) => !a.startsWith('--'));
|
|
51
|
+
if (!slug) throw new Error('usage: diorama playthrough <slug> [ending...] [--video]');
|
|
52
|
+
const files = new WorldFiles(slug);
|
|
53
|
+
const world = files.world();
|
|
54
|
+
// Routes come from the story as it is now, never from an old routes.json.
|
|
55
|
+
const report = checkStory(world, files.readJson(files.path('story.json')));
|
|
56
|
+
if (report.issues.length) throw new Error(`story.json does not check out:\n- ${report.issues.join('\n- ')}`);
|
|
57
|
+
const routes = report.routes.filter((r) => only.length === 0 || only.includes(r.ending));
|
|
58
|
+
const outDir = files.path('playthrough');
|
|
59
|
+
// Only this run's endings start over, so recording one ending keeps the others' videos.
|
|
60
|
+
for (const r of routes) for (const ext of ['.webm', '.sound.webm', '.mp4']) fs.rmSync(path.join(outDir, `${r.ending}${ext}`), { force: true });
|
|
61
|
+
|
|
62
|
+
const server = await serveWorld(files, port);
|
|
63
|
+
// Headless pages may play sound without a click, so the recorder hears the game from the first frame.
|
|
64
|
+
const browser = await (await chromium()).launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
|
|
65
|
+
const runs: Run[] = [];
|
|
66
|
+
// Studio counts the endings off these lines: "1/3 ...", then ok or FAIL for each.
|
|
67
|
+
console.log(`playing ${routes.length} ending${routes.length === 1 ? '' : 's'}: ${routes.map((r) => r.ending).join(', ')}`);
|
|
68
|
+
try {
|
|
69
|
+
for (const [i, route] of routes.entries()) {
|
|
70
|
+
console.log(`${i + 1}/${routes.length} playing to ${route.ending}`);
|
|
71
|
+
const opened = Date.now();
|
|
72
|
+
const page = (await browser.newPage({
|
|
73
|
+
viewport: { width: 1920, height: 1080 },
|
|
74
|
+
...(video ? { recordVideo: { dir: outDir, size: { width: 1920, height: 1080 } } } : {}),
|
|
75
|
+
})) as Page & { close(): Promise<void>; video(): { path(): Promise<string> } | null };
|
|
76
|
+
page.on('pageerror', (e) => console.log(` page error: ${e.message}`));
|
|
77
|
+
await page.goto(`http://localhost:${port}/?capture=1&cursor=0${query ? `&${query}` : ''}`);
|
|
78
|
+
await page.waitForFunction('window.diorama && window.diorama.place', undefined, { timeout: 30000 });
|
|
79
|
+
const steps: Step[] = [{ wait: lead * 1000 }, ...route.actions.map(toStep), { wait: 2500 }];
|
|
80
|
+
const started = Date.now();
|
|
81
|
+
const sound = video && (await page.evaluate<boolean>('!!(window.diorama.sound && window.diorama.data.sound && (window.diorama.sound.record(), true))'));
|
|
82
|
+
const run: Run = { ending: route.ending, reached: null, clicks: route.actions.length, seconds: 0, frames: 0 };
|
|
83
|
+
try {
|
|
84
|
+
const result = await playRoute(page, steps);
|
|
85
|
+
Object.assign(run, { reached: result.ending, frames: result.frames, seconds: Math.round(result.time * 10) / 10 });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
run.error = (error as Error).message.split('\n')[0];
|
|
88
|
+
}
|
|
89
|
+
const audio = sound ? Buffer.from(await page.evaluate<string>('window.diorama.sound.stopRecording()'), 'base64') : undefined;
|
|
90
|
+
await page.close();
|
|
91
|
+
const recorded = video ? await page.video()?.path() : undefined;
|
|
92
|
+
if (recorded) {
|
|
93
|
+
run.video = path.join(outDir, `${route.ending}.webm`);
|
|
94
|
+
fs.renameSync(recorded, run.video);
|
|
95
|
+
if (audio?.length) run.video = withSound(run.video, audio, (started - opened) / 1000);
|
|
96
|
+
}
|
|
97
|
+
runs.push(run);
|
|
98
|
+
const ok = run.reached === route.ending;
|
|
99
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${route.ending}: ${run.clicks} clicks, ${run.seconds} s game time (${((Date.now() - started) / 1000).toFixed(0)} s wall)${run.error ? ` · ${run.error}` : ''}${!ok && !run.error ? ` · reached ${run.reached ?? 'no ending'}` : ''}`);
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
await browser.close();
|
|
103
|
+
server.close();
|
|
104
|
+
}
|
|
105
|
+
const reportFile = path.join(outDir, 'report.json');
|
|
106
|
+
const earlier = fs.existsSync(reportFile) ? (files.readJson(reportFile) as Run[]).filter((r) => !runs.some((n) => n.ending === r.ending)) : [];
|
|
107
|
+
files.write(reportFile, [...earlier, ...runs]);
|
|
108
|
+
const failed = runs.filter((r) => r.reached !== r.ending);
|
|
109
|
+
if (failed.length) throw new Error(`${failed.length} of ${runs.length} ending(s) not reached in the page`);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// diorama publish <slug>: exports the world onto the repo's gh-pages branch and pushes it, so anyone can play it at a link.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
6
|
+
import { exportWorld } from './export.ts';
|
|
7
|
+
|
|
8
|
+
/** https://<owner>.github.io/<repo>/ for a GitHub remote URL (https or ssh), or null for any other host. */
|
|
9
|
+
export function pagesUrl(remote: string): string | null {
|
|
10
|
+
const m = remote.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
11
|
+
if (!m) return null;
|
|
12
|
+
const [, owner, repo] = m;
|
|
13
|
+
return repo.toLowerCase() === `${owner.toLowerCase()}.github.io` ? `https://${repo}/` : `https://${owner}.github.io/${repo}/`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The front page of the published site: one link per world folder that holds a world-data.json. */
|
|
17
|
+
export function indexPage(worlds: { slug: string; title: string }[]): string {
|
|
18
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
19
|
+
const items = worlds.map((w) => `<li><a href="./${encodeURIComponent(w.slug)}/">${esc(w.title)}</a></li>`).join('\n');
|
|
20
|
+
return `<!doctype html>
|
|
21
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Diorama worlds</title>
|
|
22
|
+
<style>body{margin:0;padding:40px 24px;background:#0b0b0e;color:#e8e2d6;font:18px/1.5 Georgia,serif}main{max-width:640px;margin:auto}a{color:#ffd37a}li{margin:8px 0}</style>
|
|
23
|
+
</head><body><main><h1>Diorama worlds</h1><p>Painted point-and-click places made with Meta Muse and SAM 3.1. Click one to play in the browser.</p>
|
|
24
|
+
<ul>
|
|
25
|
+
${items}
|
|
26
|
+
</ul><p><a href="https://github.com/gtanczyk/diorama">Source</a></p></main></body></html>
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
defineCommand({
|
|
31
|
+
name: 'publish',
|
|
32
|
+
usage: 'publish <slug> [--remote origin] [--branch gh-pages] [--no-push] put the world on GitHub Pages and print its link',
|
|
33
|
+
async run(args) {
|
|
34
|
+
const remote = takeOption(args, 'remote', 'origin');
|
|
35
|
+
const branch = takeOption(args, 'branch', 'gh-pages');
|
|
36
|
+
const push = !args.includes('--no-push');
|
|
37
|
+
const [slug] = args.filter((a) => a !== '--no-push');
|
|
38
|
+
if (!slug) throw new Error('usage: diorama publish <slug> [--remote origin] [--branch gh-pages] [--no-push]');
|
|
39
|
+
const git = (cwd: string, ...a: string[]) => execFileSync('git', a, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
40
|
+
const top = git(process.cwd(), 'rev-parse', '--show-toplevel');
|
|
41
|
+
|
|
42
|
+
// A worktree of the pages branch under .diorama/, so the checkout you work in never switches branch.
|
|
43
|
+
const site = path.join(top, '.diorama', 'pages');
|
|
44
|
+
if (fs.existsSync(site)) git(top, 'worktree', 'remove', '--force', site);
|
|
45
|
+
git(top, 'worktree', 'prune');
|
|
46
|
+
const onRemote = git(top, 'ls-remote', '--heads', remote, branch) !== '';
|
|
47
|
+
if (onRemote) {
|
|
48
|
+
git(top, 'fetch', remote, branch);
|
|
49
|
+
git(top, 'worktree', 'add', '-B', branch, site, 'FETCH_HEAD');
|
|
50
|
+
} else if (git(top, 'branch', '--list', branch) !== '') {
|
|
51
|
+
git(top, 'worktree', 'add', site, branch);
|
|
52
|
+
} else {
|
|
53
|
+
git(top, 'worktree', 'add', '--detach', site);
|
|
54
|
+
git(site, 'checkout', '--orphan', branch);
|
|
55
|
+
git(site, 'rm', '-rfq', '--ignore-unmatch', '.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await exportWorld(slug, path.join(site, slug));
|
|
59
|
+
fs.writeFileSync(path.join(site, '.nojekyll'), '');
|
|
60
|
+
const worlds = fs.readdirSync(site)
|
|
61
|
+
.filter((d) => fs.existsSync(path.join(site, d, 'world-data.json')))
|
|
62
|
+
.map((d) => ({ slug: d, title: JSON.parse(fs.readFileSync(path.join(site, d, 'world-data.json'), 'utf8')).world?.title ?? d }));
|
|
63
|
+
fs.writeFileSync(path.join(site, 'index.html'), indexPage(worlds));
|
|
64
|
+
|
|
65
|
+
git(site, 'add', '-A');
|
|
66
|
+
if (git(site, 'status', '--porcelain') === '') console.log(`publish ${slug}: nothing changed`);
|
|
67
|
+
else git(site, 'commit', '-qm', `publish ${slug}`);
|
|
68
|
+
if (push) git(site, 'push', remote, branch);
|
|
69
|
+
|
|
70
|
+
const base = pagesUrl(git(top, 'remote', 'get-url', remote));
|
|
71
|
+
console.log(`publish ${slug}: ${push ? 'pushed' : 'committed, not pushed'} to ${remote}/${branch}`);
|
|
72
|
+
if (base) {
|
|
73
|
+
console.log(` play: ${base}${slug}/`);
|
|
74
|
+
console.log(` all worlds: ${base}`);
|
|
75
|
+
if (!onRemote) console.log(` first publish: turn on Settings > Pages > Deploy from a branch > ${branch} once; the link works a minute after.`);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { defineCommand } from '../registry.ts';
|
|
3
|
+
import { WorldFiles } from '../world/files.ts';
|
|
4
|
+
import { Geometry } from '../play/geometry.ts';
|
|
5
|
+
import { Reactions } from '../play/reactions.ts';
|
|
6
|
+
import { Layout } from '../scene/layout.ts';
|
|
7
|
+
import { Story } from '../story/schema.ts';
|
|
8
|
+
import { planReactions, reactionSource, type ReactionInput } from '../scene/reactions.ts';
|
|
9
|
+
|
|
10
|
+
export function reactionInput(files: WorldFiles, key: string): ReactionInput {
|
|
11
|
+
const world = files.world(), place = world.places.find((p) => p.key === key);
|
|
12
|
+
if (!place) throw new Error(`unknown place ${key}`);
|
|
13
|
+
const read = (file: string) => fs.existsSync(file) ? files.readJson(file) : undefined;
|
|
14
|
+
const wish = read(files.place(key, 'reactions-wish.json')) as { hint?: string } | undefined;
|
|
15
|
+
const story = read(files.path('story.json'));
|
|
16
|
+
return { world, place, layout: Layout.parse(files.readJson(files.place(key, 'layout.json'))),
|
|
17
|
+
geometry: Geometry.parse(files.readJson(files.place(key, 'geometry.json'))),
|
|
18
|
+
story: story ? Story.parse(story) : null, weather: read(files.place(key, 'weather.json')), hint: wish?.hint,
|
|
19
|
+
painting: fs.readFileSync(files.place(key, 'painting.png')) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
defineCommand({
|
|
23
|
+
name: 'reactions',
|
|
24
|
+
usage: 'reactions <slug> [place...] choose and bind subtle step, area-entry and object reactions automatically',
|
|
25
|
+
async run([slug, ...only]) {
|
|
26
|
+
if (!slug) throw new Error('usage: reactions <slug> [place...]');
|
|
27
|
+
const files = new WorldFiles(slug), world = files.world();
|
|
28
|
+
for (const key of only) if (!world.places.some((p) => p.key === key)) throw new Error(`unknown place ${key}`);
|
|
29
|
+
await Promise.all(world.places.filter((p) => !only.length || only.includes(p.key)).map(async (p) => {
|
|
30
|
+
const input = reactionInput(files, p.key), out = files.place(p.key, 'reactions.json');
|
|
31
|
+
const was = fs.existsSync(out) ? Reactions.safeParse(files.readJson(out)) : undefined;
|
|
32
|
+
if (was?.success && was.data.source === reactionSource(input)) { console.log(`${p.key}: reactions unchanged`); return; }
|
|
33
|
+
const { plan, reactions } = await planReactions(files.memo, input);
|
|
34
|
+
files.write(files.place(p.key, 'reactions-plan.json'), plan);
|
|
35
|
+
// Replace only after successful binding: a failed model call preserves the last good plan.
|
|
36
|
+
files.write(out, reactions);
|
|
37
|
+
console.log(`${p.key}: reactions — ${reactions.areas.length} areas, ${reactions.interactions.length} interactions. ${reactions.scene}`);
|
|
38
|
+
}));
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// diorama reel <slug> [place]: the build-up of a place as a video, one beat per step (film/reel-<place>.mp4).
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { placeBeats, SCREEN } from '../film/reel.ts';
|
|
4
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
5
|
+
import { WorldFiles } from '../world/files.ts';
|
|
6
|
+
|
|
7
|
+
defineCommand({
|
|
8
|
+
name: 'reel',
|
|
9
|
+
usage: 'reel <slug> [place] the build-up of a place as a video, a beat per step (film/reel-<place>.mp4) [--beat 7] [--fade 0.6] [--clean: no captions]',
|
|
10
|
+
async run(args) {
|
|
11
|
+
const beat = Number(takeOption(args, 'beat', '7'));
|
|
12
|
+
const fade = Number(takeOption(args, 'fade', '0.6'));
|
|
13
|
+
const clean = args.includes('--clean');
|
|
14
|
+
const [slug, only] = args.filter((a) => a !== '--clean');
|
|
15
|
+
if (!slug) throw new Error('usage: diorama reel <slug> [place]');
|
|
16
|
+
const files = new WorldFiles(slug);
|
|
17
|
+
const placeKey = only ?? files.world().start;
|
|
18
|
+
const beats = await placeBeats(files, placeKey, clean);
|
|
19
|
+
if (beats.length < 2) throw new Error(`${placeKey} has ${beats.length} step(s) to show; run diorama build first`);
|
|
20
|
+
|
|
21
|
+
const name = `reel-${placeKey}${clean ? '-clean' : ''}`;
|
|
22
|
+
const stills = beats.map((b, i) => {
|
|
23
|
+
const file = files.path('film', name, `${String(i + 1).padStart(2, '0')}-${b.key}.png`);
|
|
24
|
+
files.write(file, b.still);
|
|
25
|
+
return file;
|
|
26
|
+
});
|
|
27
|
+
// Each still holds for one beat; a transition overlaps the end of one beat with the start of the next,
|
|
28
|
+
// so beat i starts at i * beat seconds and the film's cuts land on the click track.
|
|
29
|
+
const inputs = stills.flatMap((f, i) => ['-loop', '1', '-t', String(beat + (i < stills.length - 1 ? fade : 0)), '-i', f]);
|
|
30
|
+
let chain = '';
|
|
31
|
+
let last = '[0:v]';
|
|
32
|
+
for (let i = 1; i < stills.length; i++) {
|
|
33
|
+
const out = i === stills.length - 1 ? '[out]' : `[x${i}]`;
|
|
34
|
+
chain += `${last}[${i}:v]xfade=transition=${beats[i].enter}:duration=${fade}:offset=${i * beat}${out};`;
|
|
35
|
+
last = out;
|
|
36
|
+
}
|
|
37
|
+
const mp4 = files.path('film', `${name}.mp4`);
|
|
38
|
+
execFileSync('ffmpeg', ['-y', '-loglevel', 'error', ...inputs, '-filter_complex', `${chain}[out]fps=30,format=yuv420p[v]`, '-map', '[v]',
|
|
39
|
+
'-s', `${SCREEN.width}x${SCREEN.height}`, '-c:v', 'libx264', '-crf', '20', mp4], { stdio: 'inherit' });
|
|
40
|
+
files.write(files.path('film', `${name}.json`), {
|
|
41
|
+
place: placeKey, beat, fade,
|
|
42
|
+
beats: beats.map((b, i) => ({ key: b.key, title: b.title, by: b.by, start: i * beat, still: stills[i] })),
|
|
43
|
+
});
|
|
44
|
+
console.log(`${mp4}: ${beats.length} beats (${beats.map((b) => b.key).join(' > ')}), ${Math.round(beats.length * beat * 10) / 10}s`);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// diorama setup: what this machine still needs, and the one place a key is typed in (hidden, in the terminal).
|
|
2
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
3
|
+
import { findVault, NO_VAULT, SECRET_NAMES, SECRETS, SecretStore, type SecretName } from '../secrets.ts';
|
|
4
|
+
import { runChecks, type Check } from '../setup/checks.ts';
|
|
5
|
+
|
|
6
|
+
const MARK: Record<Check['state'], string> = { ok: ' ok ', missing: ' MISSING ', optional: ' - ', fail: ' FAIL ' };
|
|
7
|
+
|
|
8
|
+
export function printChecks(checks: Check[]) {
|
|
9
|
+
for (const c of checks) {
|
|
10
|
+
console.log(`${MARK[c.state]}${c.label}: ${c.detail}`);
|
|
11
|
+
for (const line of c.fix ?? []) console.log(` $ ${line}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Reads a line without echoing it. Only from a terminal: a pasted key never lands in shell history or argv. */
|
|
16
|
+
function askHidden(question: string): Promise<string> {
|
|
17
|
+
const stdin = process.stdin;
|
|
18
|
+
if (!stdin.isTTY) throw new Error('diorama setup reads keys from a terminal only; or set them as environment variables');
|
|
19
|
+
process.stdout.write(question);
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let value = '';
|
|
22
|
+
stdin.setRawMode(true);
|
|
23
|
+
stdin.resume();
|
|
24
|
+
stdin.setEncoding('utf8');
|
|
25
|
+
const done = (error?: Error) => {
|
|
26
|
+
stdin.setRawMode(false);
|
|
27
|
+
stdin.pause();
|
|
28
|
+
stdin.off('data', onData);
|
|
29
|
+
process.stdout.write('\n');
|
|
30
|
+
if (error) reject(error);
|
|
31
|
+
else resolve(value.trim());
|
|
32
|
+
};
|
|
33
|
+
const onData = (chunk: string) => {
|
|
34
|
+
for (const ch of chunk) {
|
|
35
|
+
if (ch === '\r' || ch === '\n') return done();
|
|
36
|
+
if (ch === '\u0003') return done(new Error('cancelled'));
|
|
37
|
+
if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
|
|
38
|
+
else if (ch >= ' ') value += ch;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
stdin.on('data', onData);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function storeKey(name: SecretName, remove: boolean) {
|
|
46
|
+
const vault = await findVault();
|
|
47
|
+
if (!vault) throw new Error(NO_VAULT);
|
|
48
|
+
const store = new SecretStore(vault);
|
|
49
|
+
if (remove) {
|
|
50
|
+
await store.set(name, null);
|
|
51
|
+
return console.log(`removed ${name} from ${store.file}`);
|
|
52
|
+
}
|
|
53
|
+
const value = await askHidden(`${SECRETS[name].label} (${name}), hidden, Enter to skip: `);
|
|
54
|
+
if (!value) return console.log('skipped');
|
|
55
|
+
await store.set(name, value);
|
|
56
|
+
console.log(`stored ${name}: sealed in ${store.file}, its key in the ${vault.name}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
defineCommand({
|
|
60
|
+
name: 'setup',
|
|
61
|
+
usage: 'setup check this machine and store keys, typed hidden [--key NAME] [--remove NAME] [--live]',
|
|
62
|
+
async run(args) {
|
|
63
|
+
const live = args.includes('--live');
|
|
64
|
+
const key = takeOption(args, 'key', '');
|
|
65
|
+
const remove = takeOption(args, 'remove', '');
|
|
66
|
+
for (const name of [key, remove].filter(Boolean)) {
|
|
67
|
+
if (!SECRET_NAMES.includes(name as SecretName)) throw new Error(`unknown key ${name}; diorama keeps ${SECRET_NAMES.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
if (key || remove) return storeKey((key || remove) as SecretName, !!remove);
|
|
70
|
+
|
|
71
|
+
let checks = await runChecks({ live });
|
|
72
|
+
printChecks(checks);
|
|
73
|
+
// In a terminal, offer to store each missing key right away.
|
|
74
|
+
const missing = checks.filter((c) => SECRET_NAMES.includes(c.id as SecretName) && c.state !== 'ok');
|
|
75
|
+
if (process.stdin.isTTY && missing.length && (await findVault())) {
|
|
76
|
+
console.log('');
|
|
77
|
+
for (const c of missing) await storeKey(c.id as SecretName, false);
|
|
78
|
+
checks = await runChecks({ live });
|
|
79
|
+
console.log('');
|
|
80
|
+
printChecks(checks);
|
|
81
|
+
}
|
|
82
|
+
const blocking = checks.filter((c) => c.state === 'missing' || c.state === 'fail');
|
|
83
|
+
console.log(blocking.length ? `\n${blocking.length} to fix before a full build: ${blocking.map((c) => c.label).join(', ')}` : '\nready to build');
|
|
84
|
+
},
|
|
85
|
+
});
|