@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,386 @@
|
|
|
1
|
+
// The X clip's edit as pure functions: which spans of the recorded takes become shots (from the click log), how
|
|
2
|
+
// each shot sits in a 16:9 and a 1080x1350 frame, and where the world's own sound files go under them. diorama clip records
|
|
3
|
+
// the takes and renders what these plan (src/commands/clip.ts).
|
|
4
|
+
import type { SoundData } from '../play/sound.ts';
|
|
5
|
+
import { spokenLine } from '../play/sound.ts';
|
|
6
|
+
import type { Action } from '../story/engine.ts';
|
|
7
|
+
|
|
8
|
+
/** What happened in a recorded take, in seconds from its first video frame; x, y in page pixels. */
|
|
9
|
+
export type TakeEvent =
|
|
10
|
+
/** A cursor click: on a story action's target, or (no action) on the floor to set the hero walking. */
|
|
11
|
+
| { kind: 'click'; t: number; x: number; y: number; place: string; action?: Action }
|
|
12
|
+
/** The hero arrived at a thing and uses it (the page's 'use' event): the moment the click shows its effect. */
|
|
13
|
+
| { kind: 'use'; t: number; thing: string }
|
|
14
|
+
| { kind: 'say'; t: number; text: string; who: string }
|
|
15
|
+
/** The fade out to another place starts. */
|
|
16
|
+
| { kind: 'exit'; t: number; from: string; to: string }
|
|
17
|
+
| { kind: 'enter'; t: number; place: string };
|
|
18
|
+
|
|
19
|
+
export type Take = {
|
|
20
|
+
/** Seconds of video. */
|
|
21
|
+
duration: number;
|
|
22
|
+
/** The place the take starts in. */
|
|
23
|
+
place: string;
|
|
24
|
+
events: TakeEvent[];
|
|
25
|
+
/** Where the hero is on the page over time (page x of a 1920 px wide page). */
|
|
26
|
+
hero: Array<{ t: number; x: number }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** A recorded take, or a place coming apart into its layers (the explode of the shot's place). */
|
|
30
|
+
export type ShotSource = 'game' | 'end' | 'explode';
|
|
31
|
+
|
|
32
|
+
export type Shot = {
|
|
33
|
+
source: ShotSource;
|
|
34
|
+
/** Span of the source video, seconds. */
|
|
35
|
+
from: number;
|
|
36
|
+
to: number;
|
|
37
|
+
/** Playback speed. */
|
|
38
|
+
speed: number;
|
|
39
|
+
place: string;
|
|
40
|
+
/** Left edge of the 1080 px wide crop of the 2400x1350 frame, for the 1080x1350 version of a --zoom clip. */
|
|
41
|
+
cropX: number;
|
|
42
|
+
caption?: string;
|
|
43
|
+
/** Seconds the last frame is held at the end. */
|
|
44
|
+
hold?: number;
|
|
45
|
+
why: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const TALL = { scaledWidth: 2400, width: 1080 };
|
|
49
|
+
/** Seconds of the opening shot before the first click lands, and of the whole opening shot (the hero walks first). */
|
|
50
|
+
export const OPEN_LEAD = 0.2;
|
|
51
|
+
export const OPEN_SHOT = 2.2;
|
|
52
|
+
/** Seconds of each explode in the clip (the whole orbit-popout take, at speed 1), and the end card's last-frame hold. */
|
|
53
|
+
export const EXPLODE = 4;
|
|
54
|
+
export const END_HOLD = 0.5;
|
|
55
|
+
/** Seconds of the last place before its closing explode. */
|
|
56
|
+
const END_SHOT = 2.8;
|
|
57
|
+
/** The least the story shots get, per place, however short --seconds is. */
|
|
58
|
+
const LEAST_PER_PLACE = 2.4;
|
|
59
|
+
const BEFORE = 0.5;
|
|
60
|
+
const AFTER = 2.5;
|
|
61
|
+
const LONGEST = 4;
|
|
62
|
+
const SHORTEST = 1.2;
|
|
63
|
+
/** After 'enter' the page is still fading in from black. */
|
|
64
|
+
const FADE_IN = 0.45;
|
|
65
|
+
|
|
66
|
+
/** Flat, the orbit-popout explode shows the place's painting this many times its frame size, in the middle of a render of EXPLODE_RENDER. */
|
|
67
|
+
export const EXPLODE_FLAT_SCALE = 1.06;
|
|
68
|
+
export const EXPLODE_RENDER = { width: 1920, height: 1080 };
|
|
69
|
+
/** The fill round a frame that does not cover the clip: the frame itself, scaled to cover, blurred and darkened. */
|
|
70
|
+
export const FILL = { sigma: 45, brightness: -0.22, saturation: 0.8 };
|
|
71
|
+
|
|
72
|
+
type Size = { width: number; height: number };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An ffmpeg filter chain (one video in, one out) that shows a `src` frame whole and centred in w x h, at the full
|
|
76
|
+
* height (16:9 round a 4:3 frame) or the full width (1080x1350: 1080x810 with bars above and below), the rest filled
|
|
77
|
+
* with the same frame scaled to cover, blurred and darkened.
|
|
78
|
+
*/
|
|
79
|
+
export function fillFrame(src: Size, w: number, h: number): string {
|
|
80
|
+
const fit = w / h >= src.width / src.height ? `scale=-2:${h}` : `scale=${w}:-2`;
|
|
81
|
+
return `split[fill_a][fill_b];[fill_b]scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},gblur=sigma=${FILL.sigma},`
|
|
82
|
+
+ `eq=brightness=${FILL.brightness}:saturation=${FILL.saturation}[fill_bg];[fill_a]${fit}:flags=lanczos[fill_fg];[fill_bg][fill_fg]overlay=(W-w)/2:(H-h)/2`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* An ffmpeg filter chain that cuts an explode render round its flat painting (a place `frame` big), so the painting's
|
|
87
|
+
* flat state lands where a fillFrame'd game frame does and the cut into it does not jump in scale: 16:9 crops a 16:9
|
|
88
|
+
* box as tall as the painting; 1080x1350 crops the painting's width at the render's full height, scaled to 1080 wide
|
|
89
|
+
* on the render's own dark ground.
|
|
90
|
+
*/
|
|
91
|
+
export function explodeFrame(frame: Size, w: number, h: number): string {
|
|
92
|
+
const R = EXPLODE_RENDER;
|
|
93
|
+
const fw = Math.min(R.width, Math.round(frame.width * EXPLODE_FLAT_SCALE)), fh = Math.min(R.height, Math.round(frame.height * EXPLODE_FLAT_SCALE));
|
|
94
|
+
if (w >= h) {
|
|
95
|
+
const cw = Math.min(R.width, Math.floor((fh * w) / h / 2) * 2), ch = Math.min(fh, Math.round((cw * h) / w));
|
|
96
|
+
return `crop=${cw}:${ch}:${Math.round((R.width - cw) / 2)}:${Math.round((R.height - ch) / 2)},scale=${w}:${h}:flags=lanczos`;
|
|
97
|
+
}
|
|
98
|
+
return `crop=${fw}:${R.height}:${Math.round((R.width - fw) / 2)}:0,scale=${w}:-2:flags=lanczos,pad=${w}:${h}:0:(oh-ih)/2:color=0x090a10`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const shotSeconds = (s: Shot) => (s.to - s.from) / s.speed + (s.hold ?? 0);
|
|
102
|
+
|
|
103
|
+
/** Clip start of each shot. */
|
|
104
|
+
export function shotStarts(shots: Shot[]): number[] {
|
|
105
|
+
const starts: number[] = [];
|
|
106
|
+
let t = 0;
|
|
107
|
+
for (const s of shots) {
|
|
108
|
+
starts.push(t);
|
|
109
|
+
t += shotSeconds(s);
|
|
110
|
+
}
|
|
111
|
+
return starts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const targetOf = (a: Action | undefined) => (!a ? undefined : a.kind === 'go' ? a.via : a.kind === 'talk' ? a.npc : a.kind === 'do' ? a.thing : undefined);
|
|
115
|
+
const round = (x: number) => Math.round(x * 1000) / 1000;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The left edge of the 1080 px crop that keeps the hero (and the click, when both fit) in a 2400 px wide frame.
|
|
119
|
+
* `hero` and `click` are page x on a 1920 px wide page.
|
|
120
|
+
*/
|
|
121
|
+
export function cropFor(hero: number | undefined, click?: number): number {
|
|
122
|
+
const k = TALL.scaledWidth / 1920;
|
|
123
|
+
const max = TALL.scaledWidth - TALL.width;
|
|
124
|
+
let centre = hero === undefined ? TALL.scaledWidth / 2 : hero * k;
|
|
125
|
+
if (hero !== undefined && click !== undefined && Math.abs(click - hero) * k < TALL.width * 0.8) centre = ((hero + click) / 2) * k;
|
|
126
|
+
else if (hero === undefined && click !== undefined) centre = click * k;
|
|
127
|
+
return Math.round(Math.max(0, Math.min(max, centre - TALL.width / 2)));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function heroAt(take: Take, from: number, to: number): number | undefined {
|
|
131
|
+
const xs = take.hero.filter((h) => h.t >= from && h.t <= to).map((h) => h.x).sort((a, b) => a - b);
|
|
132
|
+
if (xs.length) return xs[Math.floor(xs.length / 2)];
|
|
133
|
+
const near = [...take.hero].sort((a, b) => Math.abs(a.t - (from + to) / 2) - Math.abs(b.t - (from + to) / 2))[0];
|
|
134
|
+
return near?.x;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Spans of a take where the page is black or fading: from each exit to just after the next enter. */
|
|
138
|
+
function darkSpans(take: Take): Array<[number, number]> {
|
|
139
|
+
const out: Array<[number, number]> = [];
|
|
140
|
+
for (const e of take.events) {
|
|
141
|
+
if (e.kind !== 'exit') continue;
|
|
142
|
+
const enter = take.events.find((n) => n.kind === 'enter' && n.t >= e.t);
|
|
143
|
+
out.push([e.t, enter ? enter.t + FADE_IN : take.duration]);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Where `place` holds at time t of the take. */
|
|
149
|
+
function placeAt(take: Take, t: number): string {
|
|
150
|
+
let place = take.place;
|
|
151
|
+
for (const e of take.events) if (e.kind === 'enter' && e.t <= t) place = e.place;
|
|
152
|
+
return place;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type Candidate = { from: number; to: number; at: number; click?: number; priority: number; place: string; why: string; keep?: boolean };
|
|
156
|
+
|
|
157
|
+
export type ClipPlanInput = {
|
|
158
|
+
game: Take;
|
|
159
|
+
end?: Take;
|
|
160
|
+
/** Target length in seconds; the story shots get what the opening, the explodes and the end leave. */
|
|
161
|
+
seconds: number;
|
|
162
|
+
/** Seconds of each place's explode take, for the places that have one. */
|
|
163
|
+
explodes?: Record<string, number>;
|
|
164
|
+
firstPlace: string;
|
|
165
|
+
lastPlace: string;
|
|
166
|
+
caption: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The shot list: the first click opens the clip 0.2 s in and the hero walks for two seconds, then the first place
|
|
171
|
+
* comes apart into its layers; a shot per story click (0.5 s before, 2-3 s after, or round its effect when the hero
|
|
172
|
+
* had far to walk), at least one per place, each place's first shot after its own explode, trimmed to the target
|
|
173
|
+
* length; the last place's explode, the last place, and its explode again under the end card.
|
|
174
|
+
*/
|
|
175
|
+
export function pickShots(input: ClipPlanInput): Shot[] {
|
|
176
|
+
const { game } = input;
|
|
177
|
+
const clicks = game.events.filter((e): e is Extract<TakeEvent, { kind: 'click' }> => e.kind === 'click');
|
|
178
|
+
if (!clicks.length) throw new Error('the game take has no clicks');
|
|
179
|
+
const first = clicks[0];
|
|
180
|
+
const from = round(Math.max(0, first.t - OPEN_LEAD));
|
|
181
|
+
const open: Shot = {
|
|
182
|
+
source: 'game', from, to: round(Math.min(game.duration, from + OPEN_SHOT)), speed: 1, place: placeAt(game, first.t),
|
|
183
|
+
cropX: cropFor(heroAt(game, from, from + OPEN_SHOT), first.x), why: 'opening click',
|
|
184
|
+
};
|
|
185
|
+
const exploded = new Set<string>();
|
|
186
|
+
const explode = (place: string, why: string): Shot[] => {
|
|
187
|
+
const seconds = input.explodes?.[place];
|
|
188
|
+
if (!seconds) return [];
|
|
189
|
+
exploded.add(place);
|
|
190
|
+
return [{ source: 'explode', from: 0, to: round(Math.min(seconds, EXPLODE)), speed: 1, place, cropX: cropFor(undefined), why }];
|
|
191
|
+
};
|
|
192
|
+
const skip = input.end ? input.lastPlace : undefined;
|
|
193
|
+
// What the explodes will take: one per place the story shots visit, one before the last place, one under the card.
|
|
194
|
+
const places = new Set([open.place, ...game.events.flatMap((e) => (e.kind === 'enter' && e.place !== skip ? [e.place] : []))]);
|
|
195
|
+
const explodeSeconds = (place: string) => Math.min(input.explodes?.[place] ?? 0, EXPLODE);
|
|
196
|
+
const tailPlaces = input.end && !places.has(input.lastPlace) ? [input.lastPlace, input.lastPlace] : [input.lastPlace];
|
|
197
|
+
const fixed = shotSeconds(open) + [...places, ...tailPlaces].reduce((n, p) => n + explodeSeconds(p), 0) + (input.end ? END_SHOT : 0) + END_HOLD;
|
|
198
|
+
const budget = Math.max(input.seconds - fixed, LEAST_PER_PLACE * Math.max(1, places.size));
|
|
199
|
+
// The last place is the end take's: the game take's few seconds there are left out.
|
|
200
|
+
const middle = middleShots(game, clicks.slice(1), open.to, darkSpans(game), budget, skip);
|
|
201
|
+
const shots: Shot[] = [open, ...explode(open.place, `${open.place} comes apart into its layers`)];
|
|
202
|
+
for (const s of middle) {
|
|
203
|
+
if (!exploded.has(s.place)) shots.push(...explode(s.place, `${s.place} comes apart into its layers`));
|
|
204
|
+
shots.push(s);
|
|
205
|
+
}
|
|
206
|
+
return [...shots, ...endShots(input, exploded, explode)];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function endShots(input: ClipPlanInput, exploded: Set<string>, explode: (place: string, why: string) => Shot[]): Shot[] {
|
|
210
|
+
const { end, lastPlace: place } = input;
|
|
211
|
+
const out: Shot[] = [];
|
|
212
|
+
if (end) {
|
|
213
|
+
if (!exploded.has(place)) out.push(...explode(place, `${place} comes apart into its layers`));
|
|
214
|
+
const click = end.events.find((e): e is Extract<TakeEvent, { kind: 'click' }> => e.kind === 'click');
|
|
215
|
+
const from = click ? Math.max(0, click.t - 0.3) : Math.min(1, end.duration / 4);
|
|
216
|
+
const to = Math.min(end.duration, from + END_SHOT);
|
|
217
|
+
out.push({ source: 'end', from: round(from), to: round(to), speed: 1, place, cropX: cropFor(heroAt(end, from, to), click?.x), why: 'the last place' });
|
|
218
|
+
}
|
|
219
|
+
const card = explode(place, `${place} comes apart under the end card`);
|
|
220
|
+
if (card.length) out.push({ ...card[0], caption: input.caption, hold: END_HOLD });
|
|
221
|
+
else if (out.length) Object.assign(out[out.length - 1], { caption: input.caption, hold: END_HOLD });
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function middleShots(game: Take, clicks: Array<Extract<TakeEvent, { kind: 'click' }>>, after: number, dark: Array<[number, number]>, budget: number, skip?: string): Shot[] {
|
|
226
|
+
const cands: Candidate[] = [];
|
|
227
|
+
const nextClick = (t: number) => clicks.find((c) => c.t > t + 0.01)?.t ?? game.duration;
|
|
228
|
+
for (const c of clicks) {
|
|
229
|
+
const a = c.action;
|
|
230
|
+
// Answers are UI text, not the place: never a shot of their own.
|
|
231
|
+
if (!a || a.kind === 'choose' || a.kind === 'leave') continue;
|
|
232
|
+
const target = targetOf(a);
|
|
233
|
+
const use = game.events.find((e) => e.kind === 'use' && e.t >= c.t && e.t <= nextClick(c.t) && e.thing === target);
|
|
234
|
+
let from: number, to: number, at: number;
|
|
235
|
+
if (!use || use.t - c.t <= AFTER) {
|
|
236
|
+
from = c.t - BEFORE;
|
|
237
|
+
to = Math.min(c.t + 3.5, Math.max(c.t + AFTER, use ? use.t + 1.2 : 0));
|
|
238
|
+
at = c.t;
|
|
239
|
+
} else {
|
|
240
|
+
// A long walk: the shot is the hero arriving and using it, not the cursor.
|
|
241
|
+
from = use.t - 1.8;
|
|
242
|
+
to = use.t + 1.4;
|
|
243
|
+
at = use.t;
|
|
244
|
+
}
|
|
245
|
+
const priority = a.kind === 'go' ? 3 : a.kind === 'do' && a.with ? 3 : a.kind === 'do' ? 2 : 1;
|
|
246
|
+
cands.push({ from, to, at, click: from <= c.t ? c.x : undefined, priority, place: c.place, why: `${a.kind} ${target}` });
|
|
247
|
+
}
|
|
248
|
+
// One shot per place change: the hero just arrived, unless a click shot already opens soon after.
|
|
249
|
+
for (const e of game.events) {
|
|
250
|
+
if (e.kind !== 'enter') continue;
|
|
251
|
+
const from = e.t + FADE_IN;
|
|
252
|
+
if (cands.some((c) => c.from >= e.t && c.from - from < 2)) continue;
|
|
253
|
+
cands.push({ from, to: from + 2.4, at: from, priority: 2.5, place: e.place, why: `arrive in ${e.place}` });
|
|
254
|
+
}
|
|
255
|
+
// Out of the dark and fades, after the opening shot, inside the take.
|
|
256
|
+
const clean: Candidate[] = [];
|
|
257
|
+
for (const c of cands) {
|
|
258
|
+
let { from, to } = c;
|
|
259
|
+
from = Math.max(from, after);
|
|
260
|
+
to = Math.min(to, game.duration);
|
|
261
|
+
for (const [d0, d1] of dark) {
|
|
262
|
+
if (to <= d0 || from >= d1) continue;
|
|
263
|
+
if (c.at < d0) to = Math.min(to, d0);
|
|
264
|
+
else from = Math.max(from, d1);
|
|
265
|
+
}
|
|
266
|
+
const place = placeAt(game, (from + to) / 2);
|
|
267
|
+
if (to - from >= SHORTEST && place !== skip) clean.push({ ...c, from, to, place });
|
|
268
|
+
}
|
|
269
|
+
clean.sort((a, b) => a.from - b.from);
|
|
270
|
+
// No overlaps: a later shot starts where the earlier one ends; a stub left over goes.
|
|
271
|
+
const shots: Candidate[] = [];
|
|
272
|
+
for (const c of clean) {
|
|
273
|
+
const prev = shots.at(-1);
|
|
274
|
+
if (prev && c.from < prev.to) {
|
|
275
|
+
if (c.priority > prev.priority && c.from - prev.from >= SHORTEST) prev.to = c.from;
|
|
276
|
+
else c.from = prev.to;
|
|
277
|
+
}
|
|
278
|
+
if (c.to - c.from >= SHORTEST) shots.push(c);
|
|
279
|
+
}
|
|
280
|
+
// Each place keeps its best shot while others are dropped.
|
|
281
|
+
for (const place of new Set(shots.map((s) => s.place))) {
|
|
282
|
+
const best = shots.filter((s) => s.place === place).sort((a, b) => b.priority - a.priority || a.from - b.from)[0];
|
|
283
|
+
best.keep = true;
|
|
284
|
+
}
|
|
285
|
+
const total = () => shots.reduce((n, s) => n + s.to - s.from, 0);
|
|
286
|
+
// Over the budget: tails come in to 2.5 s, then the least telling shots go (the latest first among equals).
|
|
287
|
+
for (const s of shots) if (total() > budget && s.to - s.from > 2.5) s.to = Math.max(s.from + 2.5, s.to - (total() - budget));
|
|
288
|
+
while (total() > budget && shots.length) {
|
|
289
|
+
const drop = [...shots].sort((a, b) => Number(!!a.keep) - Number(!!b.keep) || a.priority - b.priority || b.from - a.from)[0];
|
|
290
|
+
if (drop.keep && total() - (drop.to - drop.from) < budget * 0.6) {
|
|
291
|
+
// Only kept shots are left: shorten them all instead of losing a place.
|
|
292
|
+
const k = budget / total();
|
|
293
|
+
for (const s of shots) s.to = s.from + Math.max(SHORTEST, (s.to - s.from) * k);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
shots.splice(shots.indexOf(drop), 1);
|
|
297
|
+
}
|
|
298
|
+
// Under the budget: tails grow up to LONGEST, never into the dark or the next shot.
|
|
299
|
+
for (const [i, s] of shots.entries()) {
|
|
300
|
+
const room = budget - total();
|
|
301
|
+
if (room <= 0.05) break;
|
|
302
|
+
const limit = Math.min(shots[i + 1]?.from ?? game.duration, ...dark.filter(([d0]) => d0 >= s.to).map(([d0]) => d0), s.from + LONGEST);
|
|
303
|
+
s.to = Math.max(s.to, Math.min(limit, s.to + room));
|
|
304
|
+
}
|
|
305
|
+
return shots.map((s) => ({ source: 'game', from: round(s.from), to: round(s.to), speed: 1, place: s.place, cropX: cropFor(heroAt(game, s.from, s.to), s.click), why: s.why }));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** One sound under the clip: a file at a clip time, normalised to `lufs`, then scaled by `gain`. */
|
|
309
|
+
export type Cue = { kind: 'bed' | 'loop' | 'voice' | 'music'; file: string; at: number; seconds: number; lufs: number; gain: number; loop: boolean; fadeIn: number; fadeOut: number };
|
|
310
|
+
|
|
311
|
+
/** Gain of a place's loop by its planned level (1 quiet, 3 loud). */
|
|
312
|
+
const LOOP_GAIN = [0, 0.35, 0.5, 0.9];
|
|
313
|
+
export const LEVELS = { bed: -26, voice: -17, music: -21, final: -14 };
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The soundtrack from the world's own files: each place's bed and its always-on loops under that place's shots,
|
|
317
|
+
* the voiced lines where they were said in a shot that plays at normal speed (never two at once), and the music
|
|
318
|
+
* under all of it, fading out over the end card.
|
|
319
|
+
*/
|
|
320
|
+
export function audioPlan(shots: Shot[], takes: Partial<Record<ShotSource, Take>>, sound: SoundData | undefined, music: string | undefined): Cue[] {
|
|
321
|
+
const starts = shotStarts(shots);
|
|
322
|
+
const total = shots.reduce((n, s) => n + shotSeconds(s), 0);
|
|
323
|
+
const cues: Cue[] = [];
|
|
324
|
+
// Consecutive shots in one place share one bed, so it does not restart at each cut.
|
|
325
|
+
const spans: Array<{ place: string; from: number; to: number }> = [];
|
|
326
|
+
shots.forEach((s, i) => {
|
|
327
|
+
const prev = spans.at(-1);
|
|
328
|
+
const to = starts[i] + shotSeconds(s);
|
|
329
|
+
if (prev && prev.place === s.place) prev.to = to;
|
|
330
|
+
else spans.push({ place: s.place, from: starts[i], to });
|
|
331
|
+
});
|
|
332
|
+
for (const span of spans) {
|
|
333
|
+
const p = sound?.places[span.place];
|
|
334
|
+
if (!p) continue;
|
|
335
|
+
const seconds = round(span.to - span.from);
|
|
336
|
+
const fade = (n: number) => Math.min(n, seconds / 4);
|
|
337
|
+
const bed = p.bed && sound.files[p.bed];
|
|
338
|
+
if (bed) cues.push({ kind: 'bed', file: bed.file, at: round(span.from), seconds, lufs: LEVELS.bed, gain: 0.9, loop: true, fadeIn: fade(0.25), fadeOut: fade(0.3) });
|
|
339
|
+
for (const l of p.loops) {
|
|
340
|
+
const f = sound.files[l.id];
|
|
341
|
+
if (!f || l.when.length) continue;
|
|
342
|
+
cues.push({ kind: 'loop', file: f.file, at: round(span.from), seconds, lufs: LEVELS.bed, gain: LOOP_GAIN[l.level] ?? 0.5, loop: true, fadeIn: fade(0.25), fadeOut: fade(0.3) });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
let free = 0;
|
|
346
|
+
shots.forEach((s, i) => {
|
|
347
|
+
const take = (s.source === 'game' || s.source === 'end') && s.speed === 1 ? takes[s.source] : undefined;
|
|
348
|
+
for (const e of take?.events ?? []) {
|
|
349
|
+
if (e.kind !== 'say' || e.t < s.from || e.t >= s.to) continue;
|
|
350
|
+
const line = spokenLine(sound, e.text, e.who);
|
|
351
|
+
if (!line) continue;
|
|
352
|
+
const at = starts[i] + (e.t - s.from);
|
|
353
|
+
if (at < free - 0.05 || total - at < 0.6) continue;
|
|
354
|
+
const seconds = Math.min(line.seconds, total - at);
|
|
355
|
+
cues.push({ kind: 'voice', file: line.file, at: round(at), seconds: round(seconds), lufs: LEVELS.voice, gain: 1, loop: false, fadeIn: 0.02, fadeOut: seconds < line.seconds ? 0.3 : 0.05 });
|
|
356
|
+
free = at + line.seconds;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
if (music) cues.push({ kind: 'music', file: music, at: 0, seconds: round(total), lufs: LEVELS.music, gain: 1, loop: true, fadeIn: 0.2, fadeOut: Math.min(2.2, total / 4) });
|
|
360
|
+
return cues;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The world's sound for a clip, from what was made (sound/sounds.json) rather than what the page would play: the page
|
|
365
|
+
* leaves out a bed whose prompt the plan has since changed, but the file made is still that place's sound. Each
|
|
366
|
+
* place gets its `<place>-bed` and its `<place>-<thing>-loop` files (a loop belongs to the longest place key it starts
|
|
367
|
+
* with); a loop keeps the conditions the page knows for it, else plays always. Spoken lines come from `page`.
|
|
368
|
+
*/
|
|
369
|
+
export function clipSound(page: SoundData | undefined, made: Record<string, { file: string; kind: string; level: number }>, places: string[]): SoundData {
|
|
370
|
+
const files: SoundData['files'] = {};
|
|
371
|
+
for (const [id, s] of Object.entries(made)) {
|
|
372
|
+
if (s.kind === 'music' || s.kind === 'bed' || s.kind === 'loop' || s.kind === 'step' || s.kind === 'shot') files[id] = { file: s.file, kind: s.kind, level: s.level };
|
|
373
|
+
}
|
|
374
|
+
const owner = (id: string) => [...places].sort((a, b) => b.length - a.length).find((p) => id.startsWith(`${p}-`));
|
|
375
|
+
const out: SoundData = { ...(page ?? { shots: {} }), files, music: files.music ? 'music' : page?.music, places: {} };
|
|
376
|
+
for (const place of places) {
|
|
377
|
+
const bed = files[`${place}-bed`]?.kind === 'bed' ? `${place}-bed` : page?.places[place]?.bed;
|
|
378
|
+
const loops = Object.keys(files).filter((id) => files[id].kind === 'loop' && id.endsWith('-loop') && owner(id) === place).map((id) => {
|
|
379
|
+
const thing = id.slice(place.length + 1, -'-loop'.length);
|
|
380
|
+
const known = page?.places[place]?.loops.find((l) => l.id === id);
|
|
381
|
+
return { id, thing, level: files[id].level, when: known?.when ?? [] };
|
|
382
|
+
});
|
|
383
|
+
out.places[place] = { bed, loops, zones: page?.places[place]?.zones ?? [] };
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The build-up reel: one still per step of a place's build, from the files the steps left behind.
|
|
2
|
+
// The demo film plays these on a beat, so each still says which step it is and which model made it.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { FRAME } from '../scene/layout.ts';
|
|
6
|
+
import type { Critique } from '../scene/critic.ts';
|
|
7
|
+
import type { World } from '../world/schema.ts';
|
|
8
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
9
|
+
|
|
10
|
+
export const SCREEN = { width: 1920, height: 1080 } as const;
|
|
11
|
+
// The painting sits centred, scaled to leave room for the caption under it.
|
|
12
|
+
const SCALE = 1000 / FRAME.height;
|
|
13
|
+
const BOX = { width: Math.round(FRAME.width * SCALE), height: 1000 };
|
|
14
|
+
const LEFT = Math.round((SCREEN.width - BOX.width) / 2);
|
|
15
|
+
const TOP = 16;
|
|
16
|
+
|
|
17
|
+
export type Beat = {
|
|
18
|
+
key: string;
|
|
19
|
+
title: string;
|
|
20
|
+
/** Who made what this beat shows: a Meta model, or code. */
|
|
21
|
+
by: string;
|
|
22
|
+
still: Buffer;
|
|
23
|
+
/** How this beat comes in over the one before (an ffmpeg xfade transition). */
|
|
24
|
+
enter: 'fade' | 'wipeleft' | 'circleopen';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
28
|
+
|
|
29
|
+
/** The caption under the picture: step title and a badge naming the model. */
|
|
30
|
+
function captionSvg(title: string, by: string): string {
|
|
31
|
+
const badge = Math.round(by.length * 14 + 44);
|
|
32
|
+
return `<rect x="${LEFT}" y="${SCREEN.height - 62}" width="${badge}" height="44" rx="22" fill="#2f5bea"/>
|
|
33
|
+
<text x="${LEFT + 18}" y="${SCREEN.height - 32}" font-family="DejaVu Sans, sans-serif" font-size="22" font-weight="bold" fill="#fff">${esc(by)}</text>
|
|
34
|
+
<text x="${LEFT + badge + 20}" y="${SCREEN.height - 31}" font-family="DejaVu Sans, sans-serif" font-size="26" fill="#e8e6ee">${esc(title)}</text>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A painting-sized picture placed on the black screen, with optional drawing over it (in painting pixels). */
|
|
38
|
+
async function onScreen(picture: Buffer | null, over: string, title: string, by: string, clean: boolean): Promise<Buffer> {
|
|
39
|
+
const layers: sharp.OverlayOptions[] = [];
|
|
40
|
+
if (picture) {
|
|
41
|
+
const fitted = await sharp(picture).resize(BOX.width, BOX.height, { fit: 'fill' }).png().toBuffer();
|
|
42
|
+
layers.push({ input: fitted, left: LEFT, top: TOP });
|
|
43
|
+
}
|
|
44
|
+
const scaled = over && `<g transform="translate(${LEFT} ${TOP}) scale(${SCALE})">${over}</g>`;
|
|
45
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${SCREEN.width}" height="${SCREEN.height}">${scaled}${clean ? '' : captionSvg(title, by)}</svg>`;
|
|
46
|
+
layers.push({ input: Buffer.from(svg), left: 0, top: 0 });
|
|
47
|
+
return sharp({ create: { width: SCREEN.width, height: SCREEN.height, channels: 3, background: '#000' } })
|
|
48
|
+
.composite(layers).png().toBuffer();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The world as Spark wrote it: places as cards, exits as lines, in painting pixels. */
|
|
52
|
+
export function worldSvg(world: World, focus: string): string {
|
|
53
|
+
const n = world.places.length;
|
|
54
|
+
const w = 340, h = 140;
|
|
55
|
+
// Up to three places stand in a row; more go round an ellipse.
|
|
56
|
+
const pos = new Map(world.places.map((p, i) => {
|
|
57
|
+
if (n <= 3) return [p.key, { x: FRAME.width / 2 + (i - (n - 1) / 2) * (w + 80) - w / 2, y: 470 - h / 2 }];
|
|
58
|
+
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
|
|
59
|
+
return [p.key, { x: FRAME.width / 2 + Math.cos(a) * 360 - w / 2, y: 500 + Math.sin(a) * 230 - h / 2 }];
|
|
60
|
+
}));
|
|
61
|
+
const lines = world.places.flatMap((p) => p.exits.map((e) => {
|
|
62
|
+
const a = pos.get(p.key)!, b = pos.get(e.to);
|
|
63
|
+
return b ? `<line x1="${a.x + w / 2}" y1="${a.y + h / 2}" x2="${b.x + w / 2}" y2="${b.y + h / 2}" stroke="#7d86a8" stroke-width="4" stroke-dasharray="10 8"/>` : '';
|
|
64
|
+
}));
|
|
65
|
+
const cards = world.places.map((p) => {
|
|
66
|
+
const { x, y } = pos.get(p.key)!;
|
|
67
|
+
const hot = p.key === focus;
|
|
68
|
+
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="14" fill="${hot ? '#2f5bea' : '#1b1d26'}" stroke="${hot ? '#9fb4ff' : '#3a3f55'}" stroke-width="3"/>
|
|
69
|
+
<text x="${x + 22}" y="${y + 56}" font-family="DejaVu Sans, sans-serif" font-size="${Math.min(34, Math.floor((w - 44) / (p.key.length * 0.62)))}" font-weight="bold" fill="#fff">${esc(p.key)}</text>
|
|
70
|
+
<text x="${x + 22}" y="${y + 104}" font-family="DejaVu Sans, sans-serif" font-size="24" fill="#c9cde0">${p.things.length} things · ${p.exits.length} exit${p.exits.length === 1 ? '' : 's'}</text>`;
|
|
71
|
+
});
|
|
72
|
+
const pitch = world.pitch.length > 110 ? `${world.pitch.slice(0, 107)}…` : world.pitch;
|
|
73
|
+
return `<text x="60" y="90" font-family="DejaVu Serif, serif" font-size="52" font-weight="bold" fill="#fff">${esc(world.title)}</text>
|
|
74
|
+
<text x="60" y="140" font-family="DejaVu Sans, sans-serif" font-size="22" fill="#a9adc0">${esc(pitch)}</text>
|
|
75
|
+
${lines.join('')}${cards.join('')}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Numbered frames round the regions the critic asked to repaint, and the list of fixes in a panel. */
|
|
79
|
+
export function critiqueSvg(found: Critique, colour = '#ff4a3d', list = true): string {
|
|
80
|
+
const frames = found.issues.map((i, n) => {
|
|
81
|
+
const [x0, y0, x1, y1] = i.region;
|
|
82
|
+
return `<rect x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}" fill="none" stroke="${colour}" stroke-width="5" rx="6"/>
|
|
83
|
+
<circle cx="${x0}" cy="${y0}" r="20" fill="${colour}"/>
|
|
84
|
+
<text x="${x0}" y="${y0 + 8}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="22" font-weight="bold" fill="#fff">${n + 1}</text>`;
|
|
85
|
+
});
|
|
86
|
+
if (!list) return frames.join('');
|
|
87
|
+
const rows = found.issues.map((i, n) => {
|
|
88
|
+
const fix = i.fix.length > 78 ? `${i.fix.slice(0, 75)}…` : i.fix;
|
|
89
|
+
return `<text x="40" y="${FRAME.height - 40 - (found.issues.length - 1 - n) * 34}" font-family="DejaVu Sans, sans-serif" font-size="22" fill="#fff">${n + 1}. ${esc(i.problem)}: ${esc(fix)}</text>`;
|
|
90
|
+
});
|
|
91
|
+
const top = FRAME.height - 70 - found.issues.length * 34;
|
|
92
|
+
return `${frames.join('')}<rect x="20" y="${top}" width="${FRAME.width - 40}" height="${FRAME.height - 20 - top}" rx="12" fill="#000" fill-opacity="0.66"/>${rows.join('')}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every beat the files of one place allow, in build order; steps not run yet are left out. */
|
|
96
|
+
export async function placeBeats(files: WorldFiles, placeKey: string, clean = false): Promise<Beat[]> {
|
|
97
|
+
const world = files.world();
|
|
98
|
+
const place = world.places.find((p) => p.key === placeKey);
|
|
99
|
+
if (!place) throw new Error(`no place "${placeKey}" in ${files.slug}; places: ${world.places.map((p) => p.key).join(', ')}`);
|
|
100
|
+
const at = (...parts: string[]) => files.place(place.key, ...parts);
|
|
101
|
+
const has = (...parts: string[]) => fs.existsSync(at(...parts));
|
|
102
|
+
const read = (...parts: string[]) => fs.readFileSync(at(...parts));
|
|
103
|
+
const beats: Beat[] = [];
|
|
104
|
+
const add = async (key: string, title: string, by: string, picture: Buffer | null, over = '', enter: Beat['enter'] = 'fade') =>
|
|
105
|
+
beats.push({ key, title, by, enter, still: await onScreen(picture, over, title, by, clean) });
|
|
106
|
+
|
|
107
|
+
await add('world', `One sentence becomes a world: ${world.places.length} places, a hero, a story`, 'Muse Spark', null, worldSvg(world, place.key));
|
|
108
|
+
if (has('photo.png')) await add('photo', 'A real photo is the first place', 'Camera', read('photo.png'));
|
|
109
|
+
if (has('blockin.png')) await add('layout', 'Spark plans every thing, light and floor', 'Muse Spark', read(has('plan.png') ? 'plan.png' : 'blockin.png'));
|
|
110
|
+
if (has('painting-first.png')) await add('paint', 'Muse paints over the block-in', 'Muse Image', read('painting-first.png'), '', 'wipeleft');
|
|
111
|
+
const critique = has('critique.json') ? (JSON.parse(read('critique.json').toString()) as Critique) : null;
|
|
112
|
+
if (critique?.issues.length && has('painting-first.png')) {
|
|
113
|
+
await add('critic', `Spark finds ${critique.issues.length} thing${critique.issues.length === 1 ? '' : 's'} to fix`, 'Muse Spark', read('painting-first.png'), critiqueSvg(critique));
|
|
114
|
+
if (has('painting.png')) await add('repaint', 'Muse repaints only those regions', 'Muse Image', read('painting.png'), critiqueSvg(critique, '#3ddc84', false));
|
|
115
|
+
} else if (has('painting.png') && !has('painting-first.png')) await add('paint', 'Muse paints over the block-in', 'Muse Image', read('painting.png'), '', 'wipeleft');
|
|
116
|
+
if (has('masks', 'overlay.png')) await add('masks', 'SAM 3.1 cuts out every thing and the floor', 'SAM 3.1', read('masks', 'overlay.png'), '', 'circleopen');
|
|
117
|
+
if (has('walk.png')) await add('walk', 'Floor, scale and occlusion measured from the masks', 'SAM 3.1 + code', read('walk.png'));
|
|
118
|
+
if (has('verify.json') && has('painting.png')) {
|
|
119
|
+
const v = JSON.parse(read('verify.json').toString()) as { ok: boolean; errors: unknown[]; flags: unknown[] };
|
|
120
|
+
const mark = v.ok ? '#3ddc84' : '#ff4a3d';
|
|
121
|
+
const over = `<rect x="0" y="0" width="${FRAME.width}" height="${FRAME.height}" fill="none" stroke="${mark}" stroke-width="12"/>
|
|
122
|
+
<text x="${FRAME.width - 40}" y="80" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-size="54" font-weight="bold" fill="${mark}" stroke="#000" stroke-width="5" paint-order="stroke">${v.ok ? 'PLAYABLE' : `${v.errors.length} ERRORS`}</text>`;
|
|
123
|
+
await add('verify', v.ok ? 'Every check passes: the scene is playable' : 'Verify found problems to fix', 'code', read('painting.png'), over);
|
|
124
|
+
}
|
|
125
|
+
return beats;
|
|
126
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Smooth real-time recording of the play page: Chrome itself (new headless, on the GPU, ~60 fps; Playwright's bundled
|
|
2
|
+
// headless shell renders on SwiftShader and looks choppy), every painted frame from a CDP screencast, a drawn cursor
|
|
3
|
+
// that glides like a hand, and the frames laid out to a constant 60 fps by their timestamps.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { Browser, CDPSession, Page } from 'playwright';
|
|
8
|
+
import { chromium } from '../commands/explore.ts';
|
|
9
|
+
|
|
10
|
+
/** A white arrow drawn in the page, following the mouse; it dips on press. */
|
|
11
|
+
const CURSOR = `(() => {
|
|
12
|
+
const c = document.createElement('div');
|
|
13
|
+
c.id = '__cursor';
|
|
14
|
+
c.innerHTML = '<svg width="34" height="40" viewBox="0 0 17 20"><path d="M1 1 L1 16 L5 12.5 L8 19 L10.5 18 L7.6 11.6 L13 11.6 Z" fill="white" stroke="black" stroke-width="1.2" stroke-linejoin="round"/></svg>';
|
|
15
|
+
Object.assign(c.style, { position: 'fixed', left: '0', top: '0', zIndex: 2147483647, pointerEvents: 'none', transform: 'translate(-100px,-100px)', filter: 'drop-shadow(0 2px 3px rgba(0,0,0,.35))', transition: 'scale .08s' });
|
|
16
|
+
const put = () => document.documentElement.appendChild(c);
|
|
17
|
+
document.readyState === 'loading' ? addEventListener('DOMContentLoaded', put) : put();
|
|
18
|
+
addEventListener('mousemove', (e) => { c.style.transform = 'translate(' + (e.clientX - 3) + 'px,' + (e.clientY - 2) + 'px)'; }, true);
|
|
19
|
+
addEventListener('mousedown', () => { c.style.scale = '0.85'; }, true);
|
|
20
|
+
addEventListener('mouseup', () => { c.style.scale = '1'; }, true);
|
|
21
|
+
})()`;
|
|
22
|
+
|
|
23
|
+
const ease = (u: number) => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2);
|
|
24
|
+
|
|
25
|
+
export type Recorder = {
|
|
26
|
+
page: Page;
|
|
27
|
+
/** Wall-clock seconds (Date.now() / 1000) of each click. */
|
|
28
|
+
clicks: number[];
|
|
29
|
+
start(): Promise<void>;
|
|
30
|
+
glide(x: number, y: number, ms?: number): Promise<void>;
|
|
31
|
+
click(x: number, y: number, opts?: { ms?: number; dwell?: number; button?: 'left' | 'right' }): Promise<number>;
|
|
32
|
+
/** The cursor dips as in a click, without a click reaching the page. */
|
|
33
|
+
tap(x: number, y: number, opts?: { ms?: number; dwell?: number }): Promise<number>;
|
|
34
|
+
/** Writes `mp4` at a constant `fps`; returns the wall-clock second of its first frame and its length. */
|
|
35
|
+
stop(mp4: string, fps?: number): Promise<{ first: number; seconds: number; frames: number }>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Opens a page of width x height (page pixels) recording into `work` (frames are kept there until close). */
|
|
40
|
+
export async function openRecorder(width: number, height: number, work: string, log: (s: string) => void = console.log): Promise<Recorder> {
|
|
41
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
42
|
+
fs.mkdirSync(path.join(work, 'frames'), { recursive: true });
|
|
43
|
+
const pw = (await chromium()) as unknown as typeof import('playwright').chromium;
|
|
44
|
+
const args = ['--autoplay-policy=no-user-gesture-required', '--hide-scrollbars'];
|
|
45
|
+
let browser: Browser;
|
|
46
|
+
try {
|
|
47
|
+
browser = await pw.launch({ channel: 'chrome', args });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
log(`Chrome is not installed (${(error as Error).message.split('\n')[0]}); recording with Playwright's own browser, which may be choppy`);
|
|
50
|
+
browser = await pw.launch({ args });
|
|
51
|
+
}
|
|
52
|
+
const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
|
|
53
|
+
await ctx.addInitScript(CURSOR);
|
|
54
|
+
const page = await ctx.newPage();
|
|
55
|
+
const cdp: CDPSession = await ctx.newCDPSession(page);
|
|
56
|
+
const frames: Array<{ file: string; t: number }> = [];
|
|
57
|
+
let recording = false;
|
|
58
|
+
cdp.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
|
|
59
|
+
cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
|
60
|
+
if (!recording || metadata.timestamp === undefined) return;
|
|
61
|
+
const file = path.join(work, 'frames', `${String(frames.length).padStart(6, '0')}.jpg`);
|
|
62
|
+
fs.writeFileSync(file, Buffer.from(data, 'base64'));
|
|
63
|
+
frames.push({ file, t: metadata.timestamp });
|
|
64
|
+
});
|
|
65
|
+
let mouse = { x: width / 2, y: height / 2 };
|
|
66
|
+
const glide = async (x: number, y: number, ms = 550) => {
|
|
67
|
+
const from = { ...mouse }, n = Math.max(8, Math.round(ms / 16));
|
|
68
|
+
for (let i = 1; i <= n; i++) {
|
|
69
|
+
const u = ease(i / n);
|
|
70
|
+
await page.mouse.move(from.x + (x - from.x) * u, from.y + (y - from.y) * u);
|
|
71
|
+
await page.waitForTimeout(ms / n);
|
|
72
|
+
}
|
|
73
|
+
mouse = { x, y };
|
|
74
|
+
};
|
|
75
|
+
const rec: Recorder = {
|
|
76
|
+
page,
|
|
77
|
+
clicks: [],
|
|
78
|
+
async start() {
|
|
79
|
+
await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 90, maxWidth: width, maxHeight: height, everyNthFrame: 1 });
|
|
80
|
+
recording = true;
|
|
81
|
+
},
|
|
82
|
+
glide,
|
|
83
|
+
async click(x, y, opts = {}) {
|
|
84
|
+
await glide(x, y, opts.ms);
|
|
85
|
+
await page.waitForTimeout(opts.dwell ?? 300);
|
|
86
|
+
const at = Date.now() / 1000;
|
|
87
|
+
rec.clicks.push(at);
|
|
88
|
+
await page.mouse.down({ button: opts.button ?? 'left' });
|
|
89
|
+
await page.waitForTimeout(70);
|
|
90
|
+
await page.mouse.up({ button: opts.button ?? 'left' });
|
|
91
|
+
return at;
|
|
92
|
+
},
|
|
93
|
+
async tap(x, y, opts = {}) {
|
|
94
|
+
await glide(x, y, opts.ms);
|
|
95
|
+
await page.waitForTimeout(opts.dwell ?? 300);
|
|
96
|
+
const at = Date.now() / 1000;
|
|
97
|
+
rec.clicks.push(at);
|
|
98
|
+
await page.evaluate(`document.getElementById('__cursor') && (document.getElementById('__cursor').style.scale = '0.85')`);
|
|
99
|
+
await page.waitForTimeout(90);
|
|
100
|
+
await page.evaluate(`document.getElementById('__cursor') && (document.getElementById('__cursor').style.scale = '1')`);
|
|
101
|
+
return at;
|
|
102
|
+
},
|
|
103
|
+
async stop(mp4, fps = 60) {
|
|
104
|
+
recording = false;
|
|
105
|
+
await cdp.send('Page.stopScreencast').catch(() => {});
|
|
106
|
+
const end = Date.now() / 1000;
|
|
107
|
+
if (!frames.length) throw new Error('the screencast recorded no frames');
|
|
108
|
+
// Each frame holds until the next one was painted (the screencast may deliver them slightly out of order).
|
|
109
|
+
frames.sort((a, b) => a.t - b.t);
|
|
110
|
+
const lines = frames.map((f, i) => `file '${f.file}'\nduration ${Math.max(0.001, (frames[i + 1]?.t ?? end) - f.t).toFixed(4)}\n`);
|
|
111
|
+
const list = path.join(work, 'frames.txt');
|
|
112
|
+
fs.writeFileSync(list, lines.join('') + `file '${frames.at(-1)!.file}'\n`);
|
|
113
|
+
execFileSync('ffmpeg', ['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-vf', `scale=${width}:${height}:flags=lanczos,fps=${fps},format=yuv420p`,
|
|
114
|
+
'-c:v', 'libx264', '-crf', '16', '-preset', 'medium', mp4]);
|
|
115
|
+
return { first: frames[0].t, seconds: end - frames[0].t, frames: frames.length };
|
|
116
|
+
},
|
|
117
|
+
async close() {
|
|
118
|
+
await browser.close().catch(() => {});
|
|
119
|
+
fs.rmSync(path.join(work, 'frames'), { recursive: true, force: true });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
return rec;
|
|
123
|
+
}
|