@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,161 @@
|
|
|
1
|
+
import { worldStyleReference } from '../world/style-reference.ts';
|
|
2
|
+
// diorama cast <slug>: the hero and every NPC of world.json as cut-out figures the runtime walks around.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { bindExistingAnimation, bindAnimation } from '../scene/animation-source.ts';
|
|
5
|
+
import { museEdit } from '../meta/muse.ts';
|
|
6
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
9
|
+
import { castBodies, cutOut, findFigure, paintFigure, readBodies, type Who } from '../scene/cast.ts';
|
|
10
|
+
import { heldMask, heldThings } from '../scene/held.ts';
|
|
11
|
+
import { photoStanding } from '../scene/photo.ts';
|
|
12
|
+
import { rigFigure } from '../scene/rig.ts';
|
|
13
|
+
import { checkSheet } from '../scene/rig-check.ts';
|
|
14
|
+
import { PART_NAMES } from '../play/rig.ts';
|
|
15
|
+
import { fixedLooks, readCastFixes } from '../scene/cast-fixes.ts';
|
|
16
|
+
import { drawingFor, players, readDrawn } from '../world/drawings.ts';
|
|
17
|
+
import { WorldFiles } from '../world/files.ts';
|
|
18
|
+
import { approvedConcept } from '../world/sketch.ts';
|
|
19
|
+
import { castStyle, markStyled, readStyled } from '../world/styled.ts';
|
|
20
|
+
|
|
21
|
+
defineCommand({
|
|
22
|
+
name: 'cast',
|
|
23
|
+
usage: 'cast <slug> [key...] paint the hero, NPCs and players (heroes for more gamepads, drawings marked player) with Muse, cut them out with SAM 3.1 (cast/<key>.png) [--take N] [--photo me.jpg: paint the hero from a photo, standing even if they sit in it] (a character a child drew, drawings.json, is painted from the drawing)',
|
|
24
|
+
async run(args) {
|
|
25
|
+
const take = Number(takeOption(args, 'take', '0'));
|
|
26
|
+
const photoPath = takeOption(args, 'photo', '');
|
|
27
|
+
const [slug, ...only] = args;
|
|
28
|
+
if (!slug) throw new Error('usage: diorama cast <slug> [key...] [--take N]');
|
|
29
|
+
const files = new WorldFiles(slug);
|
|
30
|
+
const world = files.world();
|
|
31
|
+
const styled = readStyled(files);
|
|
32
|
+
// world --hero-photo keeps the photo next to the cast, so a plain `cast` uses it too.
|
|
33
|
+
const kept = files.path('cast', `${world.hero.key}-photo.png`);
|
|
34
|
+
const photo = photoPath ? fs.readFileSync(photoPath) : fs.existsSync(kept) ? fs.readFileSync(kept) : undefined;
|
|
35
|
+
// Only a person standing whole in the photo keeps the photo's pose; anyone sitting, lying or cut off is painted standing.
|
|
36
|
+
const standing = photo ? await photoStanding(files.memo, photo) : false;
|
|
37
|
+
if (photo && !standing) console.log(`${world.hero.key}: the photo does not show them standing whole; painted standing`);
|
|
38
|
+
// A character a child drew is painted from what was read off the drawing, as its preview was, so the preview is the figure.
|
|
39
|
+
const drawn = readDrawn(files);
|
|
40
|
+
const drawnLooks = (w: Who): Who => ({ ...w, looks: drawn.find((d) => d.key === w.key && (d.kind === 'hero' || d.kind === 'npc' || d.kind === 'player'))?.looks ?? w.looks });
|
|
41
|
+
const npcs = world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc'));
|
|
42
|
+
const fixes = readCastFixes(files);
|
|
43
|
+
const cast: Who[] = [world.hero, ...new Map(npcs.map((n) => [n.key, n])).values(), ...players(files)].filter((w) => !only.length || only.includes(w.key)).map((w) => fixedLooks(files, drawnLooks(w)));
|
|
44
|
+
const missing: string[] = [];
|
|
45
|
+
// cast/figures.json: how tall each one stands and whether upright, so a cat or a child is drawn at its own
|
|
46
|
+
// size and only those walking like a person get a rig (and later a model and sprites).
|
|
47
|
+
const bodies = { ...readBodies(files, fs.existsSync), ...(await castBodies(files.memo, world, cast)) };
|
|
48
|
+
files.write(files.path('cast', 'figures.json'), bodies);
|
|
49
|
+
const odd = Object.entries(bodies).filter(([, b]) => Math.abs(b.height - 1.62) > 0.25 || !b.upright);
|
|
50
|
+
if (odd.length) console.log(`bodies: ${odd.map(([k, b]) => `${k} ${b.height} m${b.upright ? '' : ', not upright'}`).join(', ')}`);
|
|
51
|
+
await Promise.all(cast.map(async (who) => {
|
|
52
|
+
// The person's description and chosen take (cast/fixes.json) win over the world's.
|
|
53
|
+
// An NPC that cannot be cut out is set aside (the game draws a stand-in) so the rest of the cast is made.
|
|
54
|
+
// A character painted before in another style (style_set) is repainted from that painting, so they stay who
|
|
55
|
+
// they were; the hero from a photo is painted from the photo again, in the new style.
|
|
56
|
+
const style = castStyle(files, world, who.key);
|
|
57
|
+
const was = styled.cast[who.key];
|
|
58
|
+
const before = files.path('cast', `${who.key}-painting.png`);
|
|
59
|
+
const heroPhoto = who.key === world.hero.key ? photo : undefined;
|
|
60
|
+
const restyle = was && was !== style && !heroPhoto && fs.existsSync(before) ? { painting: fs.readFileSync(before), was } : undefined;
|
|
61
|
+
const made = await paintFigure(files.memo, { ...world, style }, who, fixes[who.key]?.take ?? take, { photo: heroPhoto, standing, drawing: drawingFor(files, who.key), concept: approvedConcept(files, 'character', who.key), styleReference: worldStyleReference(files, style), restyle }, bodies[who.key]?.upright !== false).catch((error: Error) => {
|
|
62
|
+
if (who.key === world.hero.key) throw error;
|
|
63
|
+
missing.push(`${who.key}: ${error.message}`);
|
|
64
|
+
return null;
|
|
65
|
+
});
|
|
66
|
+
if (!made) return;
|
|
67
|
+
const { painting, figure, share, holds } = made;
|
|
68
|
+
bindExistingAnimation(files, who.key);
|
|
69
|
+
files.write(files.path('cast', `${who.key}-painting.png`), painting);
|
|
70
|
+
files.write(files.path('cast', `${who.key}.png`), figure);
|
|
71
|
+
writeCut(files, who.key, share, holds);
|
|
72
|
+
markStyled(files, 'cast', who.key, style);
|
|
73
|
+
// The same painting cut into parts, so the runtime can walk the figure.
|
|
74
|
+
if (bodies[who.key]?.upright === false) return console.log(`${who.key}: ${files.path('cast', `${who.key}.png`)} · not upright: drawn as painted`);
|
|
75
|
+
await rigCast(files, who.key, painting, holds);
|
|
76
|
+
}));
|
|
77
|
+
for (const m of missing) console.log(`set aside, shown as a stand-in: ${m}`);
|
|
78
|
+
console.log(`calls sent: muse ${files.memo.sent.muse}, sam ${files.memo.sent.sam}`);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The painting made walkable: the whole figure warped by its bones into a walk sheet (cast/<key>/walk.png) and
|
|
84
|
+
* rig.json with the joints and how the walk measured. A walk that tears is kept but marked, and the game slides
|
|
85
|
+
* the painted figure as one piece instead. Parts of an older cut-out rig are removed.
|
|
86
|
+
*/
|
|
87
|
+
export async function rigCast(files: WorldFiles, key: string, painting: Buffer, holds: string[] = []) {
|
|
88
|
+
try {
|
|
89
|
+
let { rig, parts, issues } = await rigFigure(files.memo, key, painting, 'warp', holds);
|
|
90
|
+
rig.check = rig.sheet ? await checkSheet(rig, parts[rig.sheet.file]) : undefined;
|
|
91
|
+
if (rig.check && !rig.check.ok) {
|
|
92
|
+
// Try a separate neutral source for the warp; never overwrite the creator's painting.
|
|
93
|
+
try {
|
|
94
|
+
const neutral = await museEdit(files.memo, { label: `rig-neutral:${key}`, images: [painting], size: '1024x1024',
|
|
95
|
+
prompt: 'The same character, identity, face, clothes, colours, proportions and every object they hold. Show the whole character standing still in a relaxed neutral walk-ready pose: both feet on the ground, legs slightly apart and visible, no stepping, no overlapping legs. Keep any carried object firmly in the same hand, complete and not touching the ground. No other objects. Plain flat background, no shadow, no text.' });
|
|
96
|
+
const schema = z.object({ matches: z.boolean(), neutral: z.boolean(), heldPreserved: z.boolean(), reason: z.string() });
|
|
97
|
+
const verdict = schema.parse(await askSparkJson(files.memo, { label: `rig-neutral-check:${key}`, schema, images: [painting,neutral], personal: true,
|
|
98
|
+
prompt: 'Compare the original and proposed neutral standing character. Check identity, clothing, colours and proportions match; legs are visible and not already walking; every object held in the original remains complete and held in the same hand. If none is held, heldPreserved is true. Reply JSON {"matches":true,"neutral":true,"heldPreserved":true,"reason":"short explanation"}.' }));
|
|
99
|
+
if (!verdict.matches || !verdict.neutral || !verdict.heldPreserved) throw new Error(verdict.reason || 'neutral pose did not preserve the original');
|
|
100
|
+
const candidate = await rigFigure(files.memo, key, neutral, 'warp', holds);
|
|
101
|
+
candidate.rig.check = candidate.rig.sheet ? await checkSheet(candidate.rig, candidate.parts[candidate.rig.sheet.file]) : undefined;
|
|
102
|
+
if (!candidate.rig.check?.ok) throw new Error(candidate.rig.check?.why ?? 'neutral walk did not pass');
|
|
103
|
+
rig = candidate.rig; parts = candidate.parts; issues = [...issues, 'walk prepared from a verified neutral pose'];
|
|
104
|
+
} catch (error) {
|
|
105
|
+
issues.push(`neutral walk could not be prepared: ${error instanceof Error ? error.message : String(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const name of PART_NAMES) fs.rmSync(files.path('cast', key, `${name}.png`), { force: true });
|
|
109
|
+
for (const [file, png] of Object.entries(parts)) files.write(files.path('cast', key, file), png);
|
|
110
|
+
files.write(files.path('cast', key, 'rig.json'), rig);
|
|
111
|
+
bindAnimation(files, key, 'rig');
|
|
112
|
+
files.write(rigAttemptFile(files, key), { ok: true, reason: issues.length ? issues.join('; ') : null, at: new Date().toISOString() });
|
|
113
|
+
const verdict = rig.check && !rig.check.ok ? ` · walk torn (${rig.check.why}): slides as one piece` : '';
|
|
114
|
+
console.log(`${key}: ${files.path('cast', `${key}.png`)} · walk sheet ${rig.sheet?.frames ?? 0} frames${verdict}${issues.map((i) => ` · ${i}`).join('')}`);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
117
|
+
// Kept for cast_status (rigIssue): the figure still shows, sliding as one piece, and the person sees why.
|
|
118
|
+
files.write(rigAttemptFile(files, key), { ok: false, reason, at: new Date().toISOString() });
|
|
119
|
+
console.log(`${key}: ${files.path('cast', `${key}.png`)} · no rig: ${reason}; moves as one cut-out (cast_animate gives it a 3D model)`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const rigAttemptFile = (files: WorldFiles, key: string) => files.path('cast', key, 'rig-attempt.json');
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* cast/<key>/cut.json: what the cut-out keeps beyond the body (`holds`, SAM phrases) and the share of its height the
|
|
127
|
+
* body takes (`share`), so the game draws the body at its own height and a slice of toast held over it above.
|
|
128
|
+
*/
|
|
129
|
+
export const cutFile = (files: WorldFiles, key: string) => files.path('cast', key, 'cut.json');
|
|
130
|
+
export function writeCut(files: WorldFiles, key: string, share: number, holds: string[]) {
|
|
131
|
+
files.write(cutFile(files, key), { share, holds });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
defineCommand({
|
|
135
|
+
name: 'rig',
|
|
136
|
+
usage: 'rig <slug> [key...] make the walk again from the cast paintings already painted (no Muse; SAM answers are kept), and cut the figure out again so what it holds stays on (what the description says they hold, or cast/fixes.json holds)',
|
|
137
|
+
async run(args) {
|
|
138
|
+
const [slug, ...only] = args;
|
|
139
|
+
if (!slug) throw new Error('usage: diorama rig <slug> [key...]');
|
|
140
|
+
const files = new WorldFiles(slug);
|
|
141
|
+
const world = files.world();
|
|
142
|
+
const bodies = readBodies(files, fs.existsSync);
|
|
143
|
+
const looksOf = new Map<string, string>([[world.hero.key, world.hero.looks], ...world.places.flatMap((p) => p.things.filter((t) => t.role === 'npc').map((t) => [t.key, t.looks] as [string, string])), ...players(files).map((p) => [p.key, p.looks] as [string, string])]);
|
|
144
|
+
const keys = fs.readdirSync(files.path('cast')).filter((f) => f.endsWith('-painting.png')).map((f) => f.slice(0, -'-painting.png'.length)).filter((k) => !only.length || only.includes(k));
|
|
145
|
+
for (const key of keys) {
|
|
146
|
+
const painting = fs.readFileSync(files.path('cast', `${key}-painting.png`));
|
|
147
|
+
const who = fixedLooks(files, { key, looks: looksOf.get(key) ?? '' });
|
|
148
|
+
const holds = await heldThings(files.memo, who);
|
|
149
|
+
const person = await findFigure(files.memo, who, painting);
|
|
150
|
+
if (person) {
|
|
151
|
+
const { figure, share } = await cutOut(painting, person.mask, await heldMask(files.memo, key, painting, person.mask, holds));
|
|
152
|
+
files.write(files.path('cast', `${key}.png`), figure);
|
|
153
|
+
writeCut(files, key, share, holds);
|
|
154
|
+
console.log(`${key}: cut out again${holds.length ? `, holding ${holds.join(', ')}` : ''}${share < 1 ? ` (the body is ${Math.round(share * 100)}% of its height)` : ''}`);
|
|
155
|
+
}
|
|
156
|
+
if (bodies[key]?.upright === false) continue;
|
|
157
|
+
await rigCast(files, key, painting, holds);
|
|
158
|
+
}
|
|
159
|
+
console.log(`calls sent: spark ${files.memo.sent.spark}, sam ${files.memo.sent.sam}`);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// diorama clip <slug>: a short clip of the world for X, made with no hand editing. Plays the shortest ending route in
|
|
2
|
+
// real Chrome (GPU, ~60 fps screencast, a gliding cursor) at 1440x1080 with the camera still, so the whole 4:3
|
|
3
|
+
// painting is in every shot, records the last place on its own, picks the shots from the click log, cuts in each
|
|
4
|
+
// place orbiting and popping apart into its layers (app/demo/explode-take.ts --style orbit-popout) before its first
|
|
5
|
+
// shot and again under the end card, lays the world's own sound files under it (beds, loops, voices, music) and
|
|
6
|
+
// writes film/clip.mp4, film/clip-1080x1350.mp4 and film/clip-still-N.jpg. Both show the frame whole, the rest of the
|
|
7
|
+
// clip's frame filled with the same scene blurred (fillFrame). --zoom records the old way: 1920x1080 with the camera
|
|
8
|
+
// zoomed in and tracking the hero, the tall version a crop round the hero. The page's own recorded audio drops out
|
|
9
|
+
// on a loaded machine, so it is never used.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import type { Page } from 'playwright';
|
|
16
|
+
import { audioPlan, clipSound, explodeFrame, fillFrame, LEVELS, pickShots, shotSeconds, shotStarts, type Cue, type Shot, type Take, type TakeEvent } from '../film/clip.ts';
|
|
17
|
+
import { openRecorder, type Recorder } from '../film/screencast.ts';
|
|
18
|
+
import { aimPoint, SETTLED, trackPending } from '../qa/route.ts';
|
|
19
|
+
import { defineCommand, takeOption } from '../registry.ts';
|
|
20
|
+
import { soundData } from '../sound/data.ts';
|
|
21
|
+
import { checkStory } from '../story/check.ts';
|
|
22
|
+
import { actions as storyActions, apply, startState, type Action } from '../story/engine.ts';
|
|
23
|
+
import type { Story } from '../story/schema.ts';
|
|
24
|
+
import { WorldFiles } from '../world/files.ts';
|
|
25
|
+
import { serveWorld } from './play.ts';
|
|
26
|
+
|
|
27
|
+
const ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
|
28
|
+
const FPS = 60;
|
|
29
|
+
const FONTS = ['/System/Library/Fonts/Supplemental/Arial Black.ttf', '/Library/Fonts/Arial Black.ttf', '/usr/share/fonts/truetype/msttcorefonts/Arial_Black.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'];
|
|
30
|
+
/** No words on screen but the game's own: the place name, the hover label, the controls hint and the bag go. */
|
|
31
|
+
const QUIET_UI = '#bar, #inventory, .held-cursor .label { display: none !important; }';
|
|
32
|
+
/** The recorded page: whole (the camera still, the 4:3 painting in full) or, with --zoom, zoomed in and tracking. */
|
|
33
|
+
const GAME_SIZE = { width: 1440, height: 1080 };
|
|
34
|
+
const ZOOM_SIZE = { width: 1920, height: 1080 };
|
|
35
|
+
const ZOOM_END_SIZE = { width: 1920, height: 1440 };
|
|
36
|
+
|
|
37
|
+
/** Logs the page's events and where the hero is on the page, in wall-clock seconds. */
|
|
38
|
+
const LOGGER = `(() => {
|
|
39
|
+
const g = window.diorama;
|
|
40
|
+
window.__clipLog = [];
|
|
41
|
+
window.__heroLog = [];
|
|
42
|
+
g.on((ev, d) => window.__clipLog.push({ ev, d, wall: Date.now() / 1000 }));
|
|
43
|
+
setInterval(() => {
|
|
44
|
+
if (!g.place || !g.hero) return;
|
|
45
|
+
const c = document.querySelector('canvas');
|
|
46
|
+
const r = c.getBoundingClientRect();
|
|
47
|
+
const W = g.place.g.frame.width;
|
|
48
|
+
const x = r.left + ((g.hero.x - (g.viewX || W / 2)) * g.zoom() + W / 2) * (r.width / c.width);
|
|
49
|
+
window.__heroLog.push({ wall: Date.now() / 1000, x });
|
|
50
|
+
}, 100);
|
|
51
|
+
})()`;
|
|
52
|
+
|
|
53
|
+
/** A page point on open floor between the hero and `target` (or anywhere near the hero), to set them walking. */
|
|
54
|
+
const floorPoint = (target: string | undefined) => `(() => {
|
|
55
|
+
const g = window.diorama;
|
|
56
|
+
const c = document.querySelector('canvas');
|
|
57
|
+
const r = c.getBoundingClientRect();
|
|
58
|
+
const { width: W, height: H } = g.place.g.frame;
|
|
59
|
+
const z = g.zoom(), k = r.width / c.width;
|
|
60
|
+
const toPage = (x, y) => [r.left + ((x - (g.viewX || W / 2)) * z + W / 2) * k, r.top + ((y - (g.viewY || H / 2)) * z + H / 2) * k];
|
|
61
|
+
const toFrame = (px, py) => g.unzoom((px - r.left) / r.width * c.width, (py - r.top) / r.height * c.height);
|
|
62
|
+
const hot = ${JSON.stringify(target ?? null)} && g.hots().find((h) => h.thing.key === ${JSON.stringify(target ?? null)});
|
|
63
|
+
const goal = hot ? g.approach(hot) : null;
|
|
64
|
+
const tries = [];
|
|
65
|
+
if (goal) for (const u of [0.45, 0.35, 0.55, 0.25, 0.65, 0.8]) tries.push([g.hero.x + (goal[0] - g.hero.x) * u, g.hero.y + (goal[1] - g.hero.y) * u]);
|
|
66
|
+
for (const d of [0.14, 0.09, 0.2]) for (let a = 0; a < 12; a++) tries.push([g.hero.x + Math.cos(a * Math.PI / 6) * W * d, g.hero.y + Math.sin(a * Math.PI / 6) * W * d * 0.5]);
|
|
67
|
+
for (const [fx, fy] of tries) {
|
|
68
|
+
const [px, py] = toPage(fx, fy);
|
|
69
|
+
if (px < 60 || py < 60 || px > innerWidth - 60 || py > innerHeight - 60) continue;
|
|
70
|
+
if (document.elementFromPoint(px, py) !== c) continue;
|
|
71
|
+
const f = toFrame(px, py);
|
|
72
|
+
if (g.thingAt(...f)) continue;
|
|
73
|
+
const fl = g.floorAt(...f);
|
|
74
|
+
if (!g.place.grid.walkable(...fl) || Math.hypot(fl[0] - g.hero.x, fl[1] - g.hero.y) < W * 0.05) continue;
|
|
75
|
+
return { x: px, y: py };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
})()`;
|
|
79
|
+
|
|
80
|
+
const targetOf = (a: Action) => (a.kind === 'go' ? a.via : a.kind === 'talk' ? a.npc : a.kind === 'do' ? a.thing : undefined);
|
|
81
|
+
|
|
82
|
+
type Logged = { ev: string; d: Record<string, unknown>; wall: number };
|
|
83
|
+
|
|
84
|
+
/** Turns the page's logs and our clicks into a Take on the video's clock. */
|
|
85
|
+
async function takeOf(page: Page, first: number, seconds: number, place: string, clicks: Array<{ wall: number; x: number; y: number; place: string; action?: Action }>): Promise<Take> {
|
|
86
|
+
const log = await page.evaluate<Logged[]>('window.__clipLog');
|
|
87
|
+
const hero = await page.evaluate<Array<{ wall: number; x: number }>>('window.__heroLog');
|
|
88
|
+
const t = (wall: number) => Math.round((wall - first) * 1000) / 1000;
|
|
89
|
+
const events: TakeEvent[] = clicks.map((c) => ({ kind: 'click', t: t(c.wall), x: c.x, y: c.y, place: c.place, ...(c.action && { action: c.action }) }));
|
|
90
|
+
for (const e of log) {
|
|
91
|
+
if (e.ev === 'use') events.push({ kind: 'use', t: t(e.wall), thing: String(e.d.thing) });
|
|
92
|
+
else if (e.ev === 'say') events.push({ kind: 'say', t: t(e.wall), text: String(e.d.text), who: String(e.d.who) });
|
|
93
|
+
else if (e.ev === 'exit') events.push({ kind: 'exit', t: t(e.wall), from: String(e.d.from), to: String(e.d.to) });
|
|
94
|
+
else if (e.ev === 'enter') events.push({ kind: 'enter', t: t(e.wall), place: String(e.d.place) });
|
|
95
|
+
}
|
|
96
|
+
events.sort((a, b) => a.t - b.t);
|
|
97
|
+
return { duration: Math.round(seconds * 1000) / 1000, place, events: events.filter((e) => e.t >= 0), hero: hero.map((h) => ({ t: t(h.wall), x: Math.round(h.x) })).filter((h) => h.t >= 0) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Opens the page at `query`, waits for the place, quiets the click shot and the UI text, starts the logs. */
|
|
101
|
+
async function ready(rec: Recorder, url: string, css: string, log: (s: string) => void) {
|
|
102
|
+
const { page } = rec;
|
|
103
|
+
page.on('pageerror', (e) => log(` page error: ${e.message}`));
|
|
104
|
+
await page.goto(url);
|
|
105
|
+
await page.waitForFunction('window.diorama && window.diorama.place && window.diorama.dark === 0', undefined, { timeout: 120_000 });
|
|
106
|
+
// The game's own click shot is too harsh in a clip; the soundtrack is laid in the edit anyway.
|
|
107
|
+
await page.evaluate('(() => { const m = window.diorama.sound; if (m && m.sound && m.sound.shots) delete m.sound.shots.click; m && m.ctx && m.ctx.resume(); })()');
|
|
108
|
+
await page.addStyleTag({ content: css });
|
|
109
|
+
await trackPending(page);
|
|
110
|
+
await page.evaluate(LOGGER);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const settled = (page: Page) => page.waitForFunction(SETTLED, undefined, { timeout: 120_000 });
|
|
114
|
+
|
|
115
|
+
/** Plays `actions` in real time from the first place; the floor click first, so the hero is mid-stride. */
|
|
116
|
+
async function gameTake(port: number, work: string, actions: Action[], zoom: boolean, log: (s: string) => void): Promise<{ take: Take; video: string }> {
|
|
117
|
+
const size = zoom ? ZOOM_SIZE : GAME_SIZE;
|
|
118
|
+
const rec = await openRecorder(size.width, size.height, work, log);
|
|
119
|
+
const { page } = rec;
|
|
120
|
+
try {
|
|
121
|
+
await ready(rec, `http://localhost:${port}/?parallax=1.5&sway=1${zoom ? '&track=1&fill=1' : ''}`, QUIET_UI, log);
|
|
122
|
+
await page.mouse.move(size.width * 0.57, size.height * 0.76);
|
|
123
|
+
await page.waitForTimeout(1500);
|
|
124
|
+
// The narrator's intro is over before the clip starts.
|
|
125
|
+
await settled(page);
|
|
126
|
+
await page.waitForTimeout(300);
|
|
127
|
+
const place = await page.evaluate<string>('window.diorama.place.key');
|
|
128
|
+
const clicks: Array<{ wall: number; x: number; y: number; place: string; action?: Action }> = [];
|
|
129
|
+
await rec.start();
|
|
130
|
+
await page.waitForTimeout(400);
|
|
131
|
+
const floor = await page.evaluate<{ x: number; y: number } | null>(floorPoint(actions[0] && targetOf(actions[0])));
|
|
132
|
+
if (floor) {
|
|
133
|
+
clicks.push({ wall: await rec.click(floor.x, floor.y, { ms: 250, dwell: 60 }), ...floor, place });
|
|
134
|
+
await page.waitForTimeout(650);
|
|
135
|
+
} else log(' no open floor to walk to first; the clip opens on the first story click');
|
|
136
|
+
for (const [i, a] of actions.entries()) {
|
|
137
|
+
if (i > 0) {
|
|
138
|
+
await settled(page);
|
|
139
|
+
await page.waitForTimeout(250);
|
|
140
|
+
}
|
|
141
|
+
const here = await page.evaluate<string>('window.diorama.place.key');
|
|
142
|
+
log(` ${i + 1}/${actions.length} ${JSON.stringify(a)}`);
|
|
143
|
+
if (a.kind === 'choose' || a.kind === 'leave') {
|
|
144
|
+
const button = page.locator(`#talk button[data-action=${JSON.stringify(a.kind === 'choose' ? `choose:${a.index}` : 'leave')}]`);
|
|
145
|
+
await button.scrollIntoViewIfNeeded().catch(() => {});
|
|
146
|
+
const b = await button.boundingBox();
|
|
147
|
+
if (!b) throw new Error(`no answer button for ${JSON.stringify(a)}`);
|
|
148
|
+
const at = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
149
|
+
clicks.push({ wall: await rec.click(at.x, at.y, { ms: 450, dwell: 150 }), ...at, place: here, action: a });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const step: { use: string; verb?: string; with?: string } = a.kind === 'go' ? { use: a.via } : a.kind === 'talk' ? { use: a.npc } : { use: a.thing, verb: a.verb, ...(a.with ? { with: a.with } : {}) };
|
|
153
|
+
// The bag is hidden in the clip: the item is lifted from it unseen and rides the cursor to its target.
|
|
154
|
+
const pick = async () => {
|
|
155
|
+
if (!step.with) return;
|
|
156
|
+
const slot = page.locator(`#inventory button[data-item=${JSON.stringify(step.with)}]`);
|
|
157
|
+
if (!(await slot.count())) throw new Error(`${step.with} is not in the bag`);
|
|
158
|
+
await slot.dispatchEvent('click');
|
|
159
|
+
await page.waitForTimeout(250);
|
|
160
|
+
};
|
|
161
|
+
await pick();
|
|
162
|
+
// A step the slow machine left half done (an item still in hand): drop what is held, settle, aim again.
|
|
163
|
+
let p = await aimPoint(page, step).catch(() => null);
|
|
164
|
+
if (!p) {
|
|
165
|
+
log(` retry ${step.use}`);
|
|
166
|
+
await page.keyboard.press('Escape');
|
|
167
|
+
await settled(page).catch(() => {});
|
|
168
|
+
await page.waitForTimeout(500);
|
|
169
|
+
await pick();
|
|
170
|
+
p = await aimPoint(page, step);
|
|
171
|
+
}
|
|
172
|
+
if (!p) throw new Error(`no clickable pixel for ${step.use}`);
|
|
173
|
+
clicks.push({ wall: await rec.click(p.x, p.y, { ms: 650, button: step.verb === 'look' ? 'right' : 'left' }), ...p, place: here, action: a });
|
|
174
|
+
}
|
|
175
|
+
await settled(page).catch(() => {});
|
|
176
|
+
await page.waitForTimeout(2500);
|
|
177
|
+
const video = path.join(work, 'video.mp4');
|
|
178
|
+
const v = await rec.stop(video, FPS);
|
|
179
|
+
return { take: await takeOf(page, v.first, v.seconds, place, clicks), video };
|
|
180
|
+
} finally {
|
|
181
|
+
await rec.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The last place on its own, whole (no fill; 1440x1080, or 1920x1440 with --zoom so a tall thing stays in view), the
|
|
187
|
+
* hero setting off for the next thing the route does there: really, when the fresh place allows it (a talk), else a
|
|
188
|
+
* walk to it.
|
|
189
|
+
*/
|
|
190
|
+
async function endTake(port: number, work: string, place: string, next: Action | undefined, valid: boolean, zoom: boolean, log: (s: string) => void): Promise<{ take: Take; video: string }> {
|
|
191
|
+
const size = zoom ? ZOOM_END_SIZE : GAME_SIZE;
|
|
192
|
+
const rec = await openRecorder(size.width, size.height, work, log);
|
|
193
|
+
const { page } = rec;
|
|
194
|
+
try {
|
|
195
|
+
await ready(rec, `http://localhost:${port}/?place=${encodeURIComponent(place)}¶llax=1.5&sway=1`, `${QUIET_UI} #talk { display: none !important; }`, log);
|
|
196
|
+
await page.waitForFunction('!window.diorama.busy && !window.diorama.speech', undefined, { timeout: 60_000 }).catch(() => {});
|
|
197
|
+
await page.mouse.move(size.width / 2, size.height * 0.62);
|
|
198
|
+
await page.waitForTimeout(1200);
|
|
199
|
+
const clicks: Array<{ wall: number; x: number; y: number; place: string; action?: Action }> = [];
|
|
200
|
+
await rec.start();
|
|
201
|
+
await page.waitForTimeout(300);
|
|
202
|
+
const target = next && targetOf(next);
|
|
203
|
+
const step = next && target ? (next.kind === 'do' ? { use: target, verb: next.verb } : { use: target }) : undefined;
|
|
204
|
+
const aimed = valid && step ? await aimPoint(page, step).catch(() => null) : null;
|
|
205
|
+
if (aimed && next) {
|
|
206
|
+
log(` end: ${JSON.stringify(next)}`);
|
|
207
|
+
clicks.push({ wall: await rec.click(aimed.x, aimed.y, { ms: 400, dwell: 150 }), ...aimed, place, action: next });
|
|
208
|
+
} else {
|
|
209
|
+
// Not something the fresh place allows: the hero only walks to it.
|
|
210
|
+
const rect = target ? await page.evaluate<{ x: number; y: number } | null>(`(() => { const r = window.diorama.pageRect(${JSON.stringify(target)}); return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null; })()`) : null;
|
|
211
|
+
const goal = rect && await page.evaluate<boolean>(`(() => { const g = window.diorama; const hot = g.hots().find((h) => h.thing.key === ${JSON.stringify(target)}); if (!hot) return false; void g.walkTo(...g.approach(hot)); return true; })()`).then(() => rect);
|
|
212
|
+
const at = goal ?? (await page.evaluate<{ x: number; y: number } | null>(floorPoint(undefined)));
|
|
213
|
+
if (at && goal) clicks.push({ wall: await rec.tap(at.x, at.y, { ms: 400, dwell: 150 }), ...at, place });
|
|
214
|
+
else if (at) clicks.push({ wall: await rec.click(at.x, at.y, { ms: 400, dwell: 150 }), ...at, place });
|
|
215
|
+
log(` end: walk ${goal ? `to ${target}` : at ? 'across the floor' : '(nowhere to walk)'}`);
|
|
216
|
+
}
|
|
217
|
+
await page.waitForTimeout(10_000);
|
|
218
|
+
const video = path.join(work, 'video.mp4');
|
|
219
|
+
const v = await rec.stop(video, FPS);
|
|
220
|
+
return { take: await takeOf(page, v.first, v.seconds, place, clicks), video };
|
|
221
|
+
} finally {
|
|
222
|
+
await rec.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The place orbiting and popping apart into its layers (a 4 s take, app/demo/explode-take.ts); undefined when that is not there. */
|
|
227
|
+
function explodeTake(slug: string, place: string, out: string, log: (s: string) => void): string | undefined {
|
|
228
|
+
const script = path.join(ROOT, 'app/demo/explode-take.ts');
|
|
229
|
+
const tsx = path.join(ROOT, 'node_modules/.bin/tsx');
|
|
230
|
+
if (!fs.existsSync(script) || !fs.existsSync(tsx)) {
|
|
231
|
+
log(' no app/demo/explode-take.ts in this install: no layer explode');
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
execFileSync(tsx, [script, slug, place, '--style', 'orbit-popout', '--seconds', '4', '--out', out], { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'inherit'] });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
log(` explode of ${place} failed (${(error as Error).message.split('\n')[0]}); going on without it`);
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
const mp4 = path.join(out, `${slug}--${place}.mp4`);
|
|
241
|
+
return fs.existsSync(mp4) ? mp4 : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const ff = (args: string[]) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
245
|
+
export const duration = (f: string) => Number(execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f]).toString().trim());
|
|
246
|
+
|
|
247
|
+
function caption(text: string | undefined, size: number, y: number): string {
|
|
248
|
+
if (!text) return '';
|
|
249
|
+
const font = FONTS.find((f) => fs.existsSync(f));
|
|
250
|
+
const t = text.replace(/\\/g, '').replace(/'/g, '\u2019').replace(/:/g, '\\:').replace(/%/g, '\\%');
|
|
251
|
+
return `,drawtext=${font ? `fontfile='${font}':` : ''}text='${t}':fontsize=${size}:fontcolor=white:box=1:boxcolor=0x141a2e@0.88:boxborderw=${Math.round(size / 3)}:x=(w-tw)/2:y=h-th-${y}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type Sources = { game: string; end: string; explode: Record<string, string>; frames: Record<string, { width: number; height: number }> };
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* How a shot sits in a w x h clip frame: a game or end frame whole with the blurred fill round it, an explode cut
|
|
258
|
+
* round its flat painting; with --zoom the old way (the end take's middle 16:9 band, the tall one a crop round the hero).
|
|
259
|
+
*/
|
|
260
|
+
function shotFrame(s: Shot, frames: Sources['frames'], w: number, h: number, zoom: boolean): string {
|
|
261
|
+
if (s.source === 'explode') return explodeFrame(frames[s.place], w, h);
|
|
262
|
+
if (!zoom) return fillFrame(GAME_SIZE, w, h);
|
|
263
|
+
const fit = s.source === 'end' ? `crop=${ZOOM_SIZE.width}:${ZOOM_SIZE.height}:0:${(ZOOM_END_SIZE.height - ZOOM_SIZE.height) / 2}` : `scale=${ZOOM_SIZE.width}:${ZOOM_SIZE.height}`;
|
|
264
|
+
return w >= h ? fit : `${fit},scale=2400:1350:flags=lanczos,crop=1080:1350:${s.cropX}:0`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Each shot to a 16:9 and a 1080x1350 segment, then each list joined with a re-encode (a stream-copy join of mixed encodes shows colour garbage). */
|
|
268
|
+
function renderVideo(shots: Shot[], sources: Sources, work: string, zoom: boolean): { wide: string; tall: string } {
|
|
269
|
+
const wide: string[] = [], tall: string[] = [];
|
|
270
|
+
shots.forEach((s, i) => {
|
|
271
|
+
const src = s.source === 'explode' ? sources.explode[s.place] : sources[s.source];
|
|
272
|
+
if (!src) throw new Error(`no ${s.source} take for ${s.place}`);
|
|
273
|
+
const d = (s.to - s.from) / s.speed, hold = s.hold ?? 0;
|
|
274
|
+
const pts = s.speed !== 1 ? `setpts=(PTS-STARTPTS)/${s.speed},` : '';
|
|
275
|
+
const tail = hold ? `,tpad=stop_mode=clone:stop_duration=${hold}` : '';
|
|
276
|
+
const input = ['-ss', String(s.from), '-t', String(s.to - s.from), '-i', src, '-an'];
|
|
277
|
+
// The caption sits low in 16:9 and in the bar under the frame in 1080x1350.
|
|
278
|
+
for (const [w, h, size, y, crf, list, name] of [[1920, 1080, 76, 110, '16', wide, 'w'], [1080, 1350, 62, zoom ? 160 : 170, '17', tall, 'v']] as const) {
|
|
279
|
+
const out = path.join(work, `${name}${i}.mp4`);
|
|
280
|
+
ff([...input, '-filter_complex', `[0:v]${shotFrame(s, sources.frames, w, h, zoom)},${pts}fps=${FPS},format=yuv420p${caption(s.caption, size, y)}${tail}[v]`, '-map', '[v]',
|
|
281
|
+
'-t', (d + hold).toFixed(3), '-c:v', 'libx264', '-crf', crf, out]);
|
|
282
|
+
list.push(out);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
const join = (name: string, parts: string[]) => {
|
|
286
|
+
const list = path.join(work, `${name}.txt`);
|
|
287
|
+
fs.writeFileSync(list, parts.map((p) => `file '${p}'\n`).join(''));
|
|
288
|
+
const out = path.join(work, `${name}.mp4`);
|
|
289
|
+
ff(['-f', 'concat', '-safe', '0', '-i', list, '-vf', `fps=${FPS},format=yuv420p`, '-c:v', 'libx264', '-crf', '16', '-preset', 'medium', out]);
|
|
290
|
+
return out;
|
|
291
|
+
};
|
|
292
|
+
return { wide: join('wide', wide), tall: join('tall', tall) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The cues mixed to one wav: each normalised and placed, then the whole to -14 LUFS. */
|
|
296
|
+
export function renderAudio(cues: Cue[], total: number, out: string, dir: string) {
|
|
297
|
+
const inputs: string[] = [], chains: string[] = [], labels: string[] = [];
|
|
298
|
+
for (const [n, c] of cues.entries()) {
|
|
299
|
+
inputs.push(...(c.loop ? ['-stream_loop', '-1'] : []), '-i', path.join(dir, c.file));
|
|
300
|
+
const ms = Math.round(c.at * 1000);
|
|
301
|
+
chains.push(`[${n}:a]atrim=0:${c.seconds.toFixed(3)},asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,loudnorm=I=${c.lufs}:TP=${c.kind === 'voice' ? -3 : c.kind === 'music' ? -4 : -6},aresample=48000,asetpts=N/SR/TB,volume=${c.gain},`
|
|
302
|
+
+ `afade=t=in:d=${c.fadeIn},afade=t=out:st=${Math.max(0, c.seconds - c.fadeOut).toFixed(3)}:d=${c.fadeOut},adelay=${ms}|${ms},asetpts=N/SR/TB,apad=whole_dur=${total.toFixed(3)},atrim=0:${total.toFixed(3)}[x${n}]`);
|
|
303
|
+
labels.push(`[x${n}]`);
|
|
304
|
+
}
|
|
305
|
+
// Silence the length of the clip under it all; each cue is padded to that length too (amix with delayed,
|
|
306
|
+
// shorter inputs ends early).
|
|
307
|
+
inputs.push('-f', 'lavfi', '-t', total.toFixed(3), '-i', 'anullsrc=r=48000:cl=stereo');
|
|
308
|
+
labels.push(`[${cues.length}:a]`);
|
|
309
|
+
// The whole is normalised in a second pass: loudnorm right after this amix ends the track early.
|
|
310
|
+
const raw = out.replace(/(\.wav)?$/, '.raw.wav');
|
|
311
|
+
ff([...inputs, '-filter_complex', `${chains.join(';')}${chains.length ? ';' : ''}${labels.join('')}amix=inputs=${labels.length}:duration=longest:normalize=0,apad=whole_dur=${total.toFixed(3)},atrim=0:${total.toFixed(3)}[a]`,
|
|
312
|
+
'-map', '[a]', '-ar', '48000', '-c:a', 'pcm_s24le', raw]);
|
|
313
|
+
ff(['-i', raw, '-af', `apad=pad_dur=4,loudnorm=I=${LEVELS.final}:TP=-1.5:LRA=11,aresample=48000,atrim=0:${total.toFixed(3)}`, '-ar', '48000', out]);
|
|
314
|
+
fs.rmSync(raw, { force: true });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
defineCommand({
|
|
318
|
+
name: 'clip',
|
|
319
|
+
usage: 'clip <slug> a short clip for X: plays the shortest ending, cuts it round the clicks with each place coming apart and the world\'s own sound (film/clip.mp4, film/clip-1080x1350.mp4, film/clip-still-N.jpg) [--seconds 38] [--music file] [--no-explode] [--zoom] [--caption "Send me your sketch."] [--port 4198] [--keep]',
|
|
320
|
+
async run(args) {
|
|
321
|
+
// The whole clip, explodes and end card included; the story shots get what those leave (never less than ~2.4 s a place).
|
|
322
|
+
const seconds = Number(takeOption(args, 'seconds', '38'));
|
|
323
|
+
const musicArg = takeOption(args, 'music', '');
|
|
324
|
+
const port = Number(takeOption(args, 'port', '4198'));
|
|
325
|
+
const text = takeOption(args, 'caption', 'Send me your sketch.');
|
|
326
|
+
const explode = !args.includes('--no-explode');
|
|
327
|
+
const zoom = args.includes('--zoom');
|
|
328
|
+
const keep = args.includes('--keep');
|
|
329
|
+
const [slug] = args.filter((a) => !a.startsWith('--'));
|
|
330
|
+
if (!slug) throw new Error('usage: diorama clip <slug> [--seconds 38] [--music file] [--no-explode] [--zoom] [--port N]');
|
|
331
|
+
if (!(seconds > 5)) throw new Error('--seconds must be more than 5');
|
|
332
|
+
const files = new WorldFiles(slug);
|
|
333
|
+
const world = files.world();
|
|
334
|
+
const report = checkStory(world, files.readJson(files.path('story.json')));
|
|
335
|
+
if (report.issues.length || !report.story) throw new Error(`story.json does not check out:\n- ${report.issues.join('\n- ')}`);
|
|
336
|
+
const story: Story = report.story;
|
|
337
|
+
const route = [...report.routes].sort((a, b) => a.actions.length - b.actions.length)[0];
|
|
338
|
+
if (!route) throw new Error(`${slug} has no route to an ending`);
|
|
339
|
+
const log = (s: string) => console.log(s);
|
|
340
|
+
|
|
341
|
+
// The game take runs to the last change of place; the last place is recorded on its own.
|
|
342
|
+
let state = startState(world);
|
|
343
|
+
const placeBefore: string[] = [];
|
|
344
|
+
for (const a of route.actions) {
|
|
345
|
+
placeBefore.push(state.place);
|
|
346
|
+
state = apply(world, story, state, a).state;
|
|
347
|
+
}
|
|
348
|
+
const lastGo = route.actions.map((a) => a.kind).lastIndexOf('go');
|
|
349
|
+
const played = lastGo >= 0 ? route.actions.slice(0, lastGo + 1) : route.actions.slice(0, -1);
|
|
350
|
+
const firstPlace = startState(world).place;
|
|
351
|
+
const lastPlace = lastGo >= 0 ? (placeBefore[lastGo + 1] ?? state.place) : firstPlace;
|
|
352
|
+
const next = route.actions.slice(played.length).find((a) => a.kind === 'talk' || a.kind === 'do');
|
|
353
|
+
const fresh = { ...startState(world), place: lastPlace };
|
|
354
|
+
const valid = !!next && storyActions(world, story, fresh).some((a) => JSON.stringify(a) === JSON.stringify(next));
|
|
355
|
+
log(`clip ${slug}: ${route.ending} in ${route.actions.length} clicks, ${played.length} played through, ending on ${lastPlace}`);
|
|
356
|
+
|
|
357
|
+
const work = path.join(os.tmpdir(), `diorama-clip-${slug}`);
|
|
358
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
359
|
+
fs.mkdirSync(work, { recursive: true });
|
|
360
|
+
// An explode for every place the clip shows: the places the played route passes through, and the last one.
|
|
361
|
+
const explodes: Record<string, string> = {};
|
|
362
|
+
if (explode) {
|
|
363
|
+
log('explode takes (each place orbits and pops apart into its layers)');
|
|
364
|
+
for (const place of new Set([...placeBefore.slice(0, played.length), lastPlace])) {
|
|
365
|
+
const mp4 = explodeTake(slug, place, path.join(work, 'explode'), log);
|
|
366
|
+
if (mp4) explodes[place] = mp4;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const frames = Object.fromEntries(Object.keys(explodes).map((p) => [p, (files.readJson(files.path('places', p, 'geometry.json')) as { frame: { width: number; height: number } }).frame]));
|
|
370
|
+
const server = await serveWorld(files, port);
|
|
371
|
+
let game: Awaited<ReturnType<typeof gameTake>>, end: Awaited<ReturnType<typeof endTake>>;
|
|
372
|
+
try {
|
|
373
|
+
log(`game take at http://localhost:${port}`);
|
|
374
|
+
game = await gameTake(port, path.join(work, 'game'), played, zoom, log);
|
|
375
|
+
log(`end take in ${lastPlace}`);
|
|
376
|
+
end = await endTake(port, path.join(work, 'end'), lastPlace, next, valid, zoom, log);
|
|
377
|
+
} finally {
|
|
378
|
+
server.close();
|
|
379
|
+
}
|
|
380
|
+
const sources: Sources = { game: game.video, end: end.video, explode: explodes, frames };
|
|
381
|
+
|
|
382
|
+
const shots = pickShots({
|
|
383
|
+
game: game.take, end: end.take, seconds, firstPlace, lastPlace, caption: text,
|
|
384
|
+
explodes: Object.fromEntries(Object.entries(explodes).map(([p, f]) => [p, duration(f)])),
|
|
385
|
+
});
|
|
386
|
+
const total = shots.reduce((n, s) => n + shotSeconds(s), 0);
|
|
387
|
+
// What was made, even where the page now leaves a stale file out; only files that are there.
|
|
388
|
+
const madeFile = files.path('sound', 'sounds.json');
|
|
389
|
+
const made = fs.existsSync(madeFile) ? Object.fromEntries(Object.entries((files.readJson(madeFile) as { sounds: Record<string, { file: string; kind: string; level: number }> }).sounds ?? {}).filter(([, s]) => fs.existsSync(files.path(s.file)))) : {};
|
|
390
|
+
const sound = clipSound(soundData(files), made, world.places.map((p) => p.key));
|
|
391
|
+
const music = musicArg ? path.resolve(musicArg) : sound?.music ? files.path(sound.files[sound.music].file) : undefined;
|
|
392
|
+
// Cue files are relative to the world (an outside --music file as a ../ path).
|
|
393
|
+
const cues = audioPlan(shots, { game: game.take, end: end.take }, sound, music && path.relative(files.dir, music));
|
|
394
|
+
const starts = shotStarts(shots);
|
|
395
|
+
shots.forEach((s, i) => log(` ${starts[i].toFixed(2).padStart(6)}s ${s.source.padEnd(8)} ${s.from.toFixed(2)}-${s.to.toFixed(2)}${s.speed !== 1 ? ` x${s.speed}` : ''} ${s.why}`));
|
|
396
|
+
log(`${shots.length} shots, ${total.toFixed(1)} s; ${cues.filter((c) => c.kind === 'voice').length} voiced line(s)`);
|
|
397
|
+
|
|
398
|
+
log('rendering');
|
|
399
|
+
const cut = path.join(work, 'cut');
|
|
400
|
+
fs.mkdirSync(cut, { recursive: true });
|
|
401
|
+
const video = renderVideo(shots, sources, cut, zoom);
|
|
402
|
+
const mix = path.join(cut, 'mix.wav');
|
|
403
|
+
renderAudio(cues, total, mix, files.dir);
|
|
404
|
+
const film = files.path('film');
|
|
405
|
+
fs.mkdirSync(film, { recursive: true });
|
|
406
|
+
const outWide = path.join(film, 'clip.mp4'), outTall = path.join(film, 'clip-1080x1350.mp4');
|
|
407
|
+
for (const [src, out] of [[video.wide, outWide], [video.tall, outTall]]) {
|
|
408
|
+
ff(['-i', src, '-i', mix, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', out]);
|
|
409
|
+
}
|
|
410
|
+
// Stills: the first place apart, a few story shots, the last place, the end card.
|
|
411
|
+
for (const f of fs.readdirSync(film).filter((f) => /^clip-still-\d+\.jpg$/.test(f))) fs.rmSync(path.join(film, f));
|
|
412
|
+
const picks = stillShots(shots);
|
|
413
|
+
const stills = picks.map((i, n) => {
|
|
414
|
+
const file = path.join(film, `clip-still-${n + 1}.jpg`);
|
|
415
|
+
const at = starts[i] + shotSeconds(shots[i]) * (shots[i].caption ? 0.85 : 0.6);
|
|
416
|
+
ff(['-ss', at.toFixed(3), '-i', outWide, '-frames:v', '1', '-q:v', '2', file]);
|
|
417
|
+
return file;
|
|
418
|
+
});
|
|
419
|
+
files.write(path.join(film, 'clip.json'), { slug, ending: route.ending, seconds: Math.round(total * 100) / 100, shots: shots.map((s, i) => ({ at: Math.round(starts[i] * 100) / 100, ...s })), cues, takes: { game: game.take, end: end.take } });
|
|
420
|
+
if (!keep) fs.rmSync(work, { recursive: true, force: true });
|
|
421
|
+
log(`${outWide} (${duration(outWide).toFixed(1)} s)\n${outTall}\n${stills.join('\n')}${keep ? `\nworking files in ${work}` : ''}`);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
/** Up to six shots to take a still from: the first explode, story shots spread over the clip, the end, the last one. */
|
|
426
|
+
function stillShots(shots: Shot[]): number[] {
|
|
427
|
+
const story = shots.map((s, i) => (s.source === 'game' && i > 0 ? i : -1)).filter((i) => i >= 0);
|
|
428
|
+
const spread = [0, 1, 2].map((k) => story[Math.round((k * (story.length - 1)) / 2)]).filter((i) => i !== undefined);
|
|
429
|
+
const out = [shots.findIndex((s) => s.source === 'explode'), ...spread, shots.findIndex((s) => s.source === 'end'), shots.length - 1];
|
|
430
|
+
return [...new Set(out.filter((i) => i >= 0))].slice(0, 6);
|
|
431
|
+
}
|