Hacky demo changes

This commit is contained in:
Andras Schmelczer 2026-05-06 19:36:04 +01:00
parent 7cba369308
commit ea7afd618c
39 changed files with 2041 additions and 745 deletions

View file

@ -11,6 +11,7 @@
"record": "tsc && node dist/record.js",
"record:vertical": "tsc && ASPECT=9x16 node dist/record.js",
"encode": "ffmpeg -y -i output/recording.webm -c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast -movflags +faststart output/recording.mp4",
"verify-output": "tsc && node dist/verify.js",
"render": "./render.sh"
},
"dependencies": {

View file

@ -23,20 +23,16 @@ PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-admin@propertymap.local}"
PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-propertymap-dev-2024}"
PB_EMAIL="${PB_EMAIL:-demo-video@local.test}"
PB_PASSWORD="${PB_PASSWORD:-DemoVideoPass123!}"
MAX_DURATION_S="${MAX_DURATION_S:-15}"
RECORD_SCALE="${RECORD_SCALE:-2}" # 2x raw capture -> real 50fps after speed-up
OUTPUT_FPS="${OUTPUT_FPS:-50}" # matches RECORD_SCALE=2 output cadence
CAPTURE_SCALE="${CAPTURE_SCALE:-1.5}" # sharper than 1x without the 2x software-GL cost
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
# Where the homepage <video> source lives. Vite copies frontend/public/* into
# the built bundle, so updating this path is what makes the new clip appear
# on the homepage. Override if the dashboard ever moves.
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
# When in the *output* timeline (post-speedup) to grab the poster frame.
# Right-pane inspection (~10s output) is the clearest paused-state preview:
# Right-pane inspection (~16s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-8}"
POSTER_TIME_S="${POSTER_TIME_S:-16}"
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
@ -134,13 +130,12 @@ mkdir -p output
# Wipe last run's leaking artifacts so the rename step picks up *this* run.
rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed
APP_URL="$APP_URL" MAX_DURATION_S="$MAX_DURATION_S" CAPTURE_SCALE="$CAPTURE_SCALE" \
RECORD_SCALE="$RECORD_SCALE" OUTPUT_FPS="$OUTPUT_FPS" \
node dist/record.js
APP_URL="$APP_URL" node dist/record.js
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
fi
node dist/verify.js output/recording.webm
# -- encode -------------------------------------------------------------------
if [ "$DO_ENCODE" = "1" ]; then
@ -164,6 +159,8 @@ if [ "$DO_ENCODE" = "1" ]; then
ffmpeg -y -loglevel warning -i output/recording.mp4 -ss "$POSTER_TIME_S" \
-frames:v 1 -update 1 -q:v 2 \
output/poster.jpg
node dist/verify.js output/recording.mp4 output/poster.jpg
fi
# -- publish to homepage ------------------------------------------------------
@ -177,6 +174,7 @@ if [ "$DO_ENCODE" = "1" ]; then
say "Publishing to $PUBLISH_DIR"
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
node dist/verify.js "$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"
fi
# -- report -------------------------------------------------------------------

123
video/src/browser.ts Normal file
View file

@ -0,0 +1,123 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { AUTH_STATE_PATH, CAPTURE_SCALE, OUTPUT_DIR, VIDEO_SIZE, VIEWPORT } from './config.js';
export interface RecordingBrowser {
browser: Browser;
context: BrowserContext;
}
export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: CAPTURE_SCALE,
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
});
await suppressDevServerNoise(context);
return { browser, context };
}
async function suppressDevServerNoise(context: BrowserContext) {
await context.addInitScript(() => {
(window as typeof window & { __demoRecording?: boolean }).__demoRecording = true;
const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, {
construct(target, args) {
const url = String(args[0] ?? '');
const proto = (args[1] as string | string[] | undefined) ?? '';
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
if (
protoStr.includes('vite-hmr') ||
protoStr.includes('webpack') ||
url.includes('/ws') ||
url.includes('sockjs-node')
) {
const fake = new EventTarget() as WebSocket;
Object.defineProperties(fake, {
readyState: { value: RealWS.CLOSED },
url: { value: url },
protocol: { value: '' },
extensions: { value: '' },
bufferedAmount: { value: 0 },
binaryType: { value: 'blob', writable: true },
});
fake.send = () => {};
fake.close = () => fake.dispatchEvent(new Event('close'));
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
return fake;
}
return Reflect.construct(target, args);
},
});
Object.defineProperty(window.location, 'reload', {
value: () => {},
configurable: true,
});
window.addEventListener('error', (e) => e.stopImmediatePropagation(), true);
window.addEventListener('unhandledrejection', (e) => e.stopImmediatePropagation(), true);
const styleEl = document.createElement('style');
styleEl.textContent = `
vite-error-overlay,
wds-overlay,
#webpack-dev-server-client-overlay,
#webpack-dev-server-client-overlay-div,
iframe[src*="webpack-dev-server"],
iframe[id*="webpack"],
[id*="webpack-dev-server-client"],
[class*="error-overlay"],
[class*="webpack-error"] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
`;
(document.head ?? document.documentElement).appendChild(styleEl);
const killOverlay = (node: Element) => {
const tag = node.tagName?.toLowerCase();
const id = (node as HTMLElement).id?.toLowerCase() ?? '';
if (
tag === 'vite-error-overlay' ||
tag === 'wds-overlay' ||
id.includes('webpack-dev-server-client') ||
id.includes('webpack-error')
) {
(node as HTMLElement).remove();
}
};
const obs = new MutationObserver((muts) => {
for (const m of muts) {
m.addedNodes.forEach((n) => {
if (n.nodeType === 1) killOverlay(n as Element);
});
}
});
if (document.body) obs.observe(document.body, { childList: true, subtree: true });
else {
document.addEventListener('DOMContentLoaded', () =>
obs.observe(document.body, { childList: true, subtree: true })
);
}
});
}

View file

@ -50,7 +50,6 @@ export const STUBBED_TRAVEL_TIME_FILTERS: {
// component renders each travel-time entry with `data-filter-name="tt_${i}"`,
// and our stub only sets one entry, so it's tt_0.
export const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
export const TT_SLIDER_MIN = 0;
export const TT_SLIDER_MAX = 120;
export const TT_DRAG_FROM_MIN = 35; // matches AI stub max above
export const TT_DRAG_TO_MIN = 20;
@ -59,12 +58,6 @@ export const TT_DRAG_TO_MIN = 20;
// 2.4 fills most of the viewport with the prompt card without blowing up text.
export const AI_ZOOM_SCALE = Number(process.env.AI_ZOOM_SCALE ?? 2.4);
// Cluster scene: how many wheel ticks (deck.gl smooths each one) and the
// per-tick delay. ~5 ticks at -120 each gets us +2 zoom levels.
export const CLUSTER_ZOOM_TICKS = 5;
export const CLUSTER_ZOOM_DELTA = -120;
export const CLUSTER_ZOOM_TICK_MS = 90;
// Initial map view used while we navigate. The AI scene zooms in on the
// sidebar so this only matters once we zoom out.
export const INITIAL_MAP_VIEW = {
@ -73,29 +66,17 @@ export const INITIAL_MAP_VIEW = {
zoom: 11.5,
};
// Postcode pre-selected on page load. The dashboard reads ?pc= and:
// 1. fetches /api/postcode/{pc}
// 2. mapFlyToRef → zoom 16 over the postcode
// 3. handleLocationSearch → opens the right pane populated with that postcode
// We use this to guarantee the right pane is open by the time the cluster
// scene plays. The visual cursor click is then ceremonial — pane is real,
// data is real, only the causation is staged.
//
// M44FZ is in Ancoats/Northern Quarter: central enough to read as Manchester,
// and it still has matching properties after the commute is tightened to 20m.
export const PRELOAD_POSTCODE = process.env.PRELOAD_POSTCODE ?? 'M44FZ';
// Verification guard only. The renderer no longer uses this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 45);
export const MIN_DURATION_S = Number(process.env.MIN_DURATION_S ?? 10);
// Hard cap on the trimmed output. Keep the homepage demo tight; the render
// trims from the outro if a dev-server hiccup stretches a scene.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 15);
// Slow down all interactions while recording, then speed the output back up in
// ffmpeg. A higher scale makes rendering take longer, but gives the 25fps raw
// recorder enough unique frames for a smooth 50fps final without shortcut cuts.
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3.5));
// Slow down all interactions while recording, then speed the output back up
// in ffmpeg. 2x gives a real 50fps final video from Playwright's 25fps raw
// recorder without making the take painfully long.
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 2));
// Target fps of the FINAL output. With RECORD_SCALE=2 this matches the real
// captured frame cadence, so the MP4 does not need synthetic interpolation.
// Target fps of the FINAL output.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);
// Brand strings for the outro card.

View file

@ -1,4 +1,5 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
@ -110,17 +111,19 @@ export async function installCursor(page: Page): Promise<void> {
#__demo-outro-card {
text-align: center;
color: white;
opacity: 1;
transform: translateY(0) scale(1);
opacity: 0;
transform: translateY(12px) scale(0.985);
position: relative;
z-index: 1;
display: block !important;
visibility: visible !important;
animation: __demo-outro-pop 520ms cubic-bezier(0.22,1,0.36,1) both;
}
#__demo-outro.visible #__demo-outro-card {
animation: __demo-outro-pop 620ms cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes __demo-outro-pop {
0% { transform: translateY(10px) scale(0.985); }
100% { transform: translateY(0) scale(1); }
0% { opacity: 0; transform: translateY(12px) scale(0.985); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
#__demo-outro-brand {
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
@ -177,6 +180,15 @@ export async function installCursor(page: Page): Promise<void> {
},
{ passive: true, capture: true }
);
(window as typeof window & {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}).__demoMoveCursor = (x, y, durationMs) => {
cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`;
cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`;
window.setTimeout(() => {
cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out';
}, durationMs + 40);
};
window.addEventListener(
'mousedown',
@ -236,6 +248,29 @@ export async function flashRect(
}, rect);
}
export async function visualClick(
page: Page,
point: { x: number; y: number },
rippleColor = 'rgba(20, 184, 166, 0.9)'
): Promise<void> {
await page.evaluate(
({ point, rippleColor }) => {
const cursor = document.getElementById('__demo-cursor');
cursor?.classList.add('click');
window.setTimeout(() => cursor?.classList.remove('click'), 140);
const r = document.createElement('div');
r.className = '__demo-ripple';
r.style.left = `${point.x}px`;
r.style.top = `${point.y}px`;
r.style.borderColor = rippleColor;
document.body.appendChild(r);
window.setTimeout(() => r.remove(), 650);
},
{ point, rippleColor }
);
}
export async function showOutro(
page: Page,
brand: string,
@ -247,7 +282,6 @@ export async function showOutro(
document.getElementById('__demo-caption')?.classList.remove('visible');
const el = document.createElement('div');
el.id = '__demo-outro';
el.className = 'visible';
el.innerHTML = `
<div id="__demo-outro-card">
<div id="__demo-outro-brand">${brand}</div>
@ -255,6 +289,9 @@ export async function showOutro(
<div id="__demo-outro-url">${url}</div>
</div>`;
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => el.classList.add('visible'));
});
},
{ brand, tagline, url }
);
@ -336,79 +373,30 @@ export async function zoomTo(
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs * RECORD_SCALE);
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
await page.evaluate(
({ dx, dy, scale, durationMs }) => {
({ dx, dy, scale, transitionMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transition = `transform ${transitionMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
},
{ dx, dy, scale, durationMs }
);
}
/**
* Zoom in such that the focus point STAYS where it is on screen only the
* surroundings expand outward. Use when the cursor is hovering over a target
* we want to keep clickable: clicks at the focus point still map to the
* same DOM/canvas pixel, both pre- and post-zoom, so deck.gl hit-tests work.
*
* Contrast with zoomTo, which translates the focus to viewport centre.
*/
export async function zoomAt(
page: Page,
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
await page.evaluate(
({ scale, focusX, focusY, durationMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transformOrigin = `${focusX}px ${focusY}px`;
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `scale(${scale})`;
},
{ scale, focusX, focusY, durationMs }
{ dx, dy, scale, transitionMs }
);
}
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
await page.evaluate((durationMs) => {
const transitionMs = Math.round(durationMs * RECORD_SCALE);
await page.evaluate((transitionMs) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transition = `transform ${transitionMs}ms cubic-bezier(0.22, 1, 0.36, 1)`;
wrap.style.transform = `translate(0px, 0px) scale(1)`;
}, durationMs);
}
/**
* Snap-set the wrapper transform with no transition. Use for the very first
* frame so the recording opens already-zoomed instead of zooming in from 1.
*/
export async function zoomToInstant(
page: Page,
opts: { scale: number; focusX: number; focusY: number }
): Promise<void> {
const { scale, focusX, focusY } = opts;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
await page.evaluate(
({ dx, dy, scale }) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;
wrap.style.transition = 'none';
wrap.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
// Force a reflow so the next assignment with a transition actually
// animates instead of being collapsed with this one.
void wrap.offsetHeight;
},
{ dx, dy, scale }
);
}, transitionMs);
}
/**
@ -447,3 +435,83 @@ export async function scrollPaneTo(
{ selector, top }
);
}
export async function waitForAnimationFrames(page: Page, frames = 3): Promise<void> {
await page.evaluate(
(frameCount) =>
new Promise<void>((resolve) => {
let seen = 0;
const tick = () => {
seen += 1;
if (seen >= frameCount) resolve();
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
frames
);
}
export async function getDemoMapSettleVersion(page: Page): Promise<number> {
return page.evaluate(
() =>
(
window as typeof window & {
__demoMapSettleVersion?: number;
}
).__demoMapSettleVersion ?? 0
);
}
export async function waitForDemoMapSettled(
page: Page,
timeoutMs = 12000,
afterVersion = -1
): Promise<void> {
await page.waitForFunction(
(version) => {
const demo = window as typeof window & {
__demoMapSettled?: boolean;
__demoMapSettleVersion?: number;
__demoMapIdle?: boolean;
};
return (
demo.__demoMapSettled === true &&
demo.__demoMapIdle === true &&
(demo.__demoMapSettleVersion ?? 0) > version
);
},
afterVersion,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}
export async function waitForCurrentDemoMapSettled(page: Page, timeoutMs = 12000): Promise<void> {
await page.waitForFunction(
() => {
const demo = window as typeof window & {
__demoMapSettled?: boolean;
__demoMapIdle?: boolean;
};
return demo.__demoMapSettled === true && demo.__demoMapIdle === true;
},
undefined,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}
export async function waitForDemoSelectionReady(page: Page, timeoutMs = 12000): Promise<void> {
await page.waitForFunction(
() =>
(
window as typeof window & {
__demoSelectionReady?: boolean;
}
).__demoSelectionReady === true,
undefined,
{ timeout: timeoutMs }
);
await waitForAnimationFrames(page, 4);
}

View file

@ -12,30 +12,22 @@ export const sleep = (ms: number) =>
export const easeInOut = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// Slight overshoot then settle — gives clicks a tactile feel when paired with ripple.
export const easeOutBack = (t: number): number => {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};
interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
realMouse?: boolean;
/**
* Override the per-step CDP cost used to size the loop. Default 35ms is
* right for free cursor moves. During a drag, every mouse.move fires a
* pointermove React re-render thumb position update on the same
* thread, pushing effective per-step cost to ~100ms. Pass that for drags
* Override the per-step CDP cost used to size the loop. During a drag, every
* mouse.move fires a pointermove -> React re-render -> thumb position update
* on the same thread, pushing effective per-step cost higher. Pass that for drags
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
*/
stepBudgetMs?: number;
}
// Empirical Playwright→Chromium CDP roundtrip cost for a mouse.move command
// while recording the 4K, software-GL dashboard. It is much higher than a
// simple page because every move competes with map rendering and video capture.
const CDP_MOVE_MS = 90;
// Empirical Playwright-to-Chromium CDP roundtrip cost for a mouse.move command
// while recording the software-GL dashboard.
const CDP_MOVE_MS = 70;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
@ -51,10 +43,36 @@ export async function smoothMove(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ durationMs = 600, ease = easeInOut, stepBudgetMs = CDP_MOVE_MS }: MoveOptions = {}
{
durationMs = 600,
ease = easeInOut,
realMouse = false,
stepBudgetMs = CDP_MOVE_MS,
}: MoveOptions = {}
): Promise<void> {
const wallDuration = durationMs * RECORD_SCALE;
const steps = Math.max(2, Math.min(28, Math.round(wallDuration / stepBudgetMs)));
if (!realMouse) {
const animated = await page.evaluate(
({ x, y, wallDuration }) => {
const move = (
window as typeof window & {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}
).__demoMoveCursor;
if (!move) return false;
move(x, y, wallDuration);
return true;
},
{ x: to.x, y: to.y, wallDuration }
);
if (animated) {
await new Promise((resolve) => setTimeout(resolve, wallDuration));
await page.mouse.move(to.x, to.y);
return;
}
}
const steps = Math.max(2, Math.min(96, Math.round(wallDuration / stepBudgetMs)));
for (let i = 1; i <= steps; i++) {
const t = ease(i / steps);
const x = from.x + (to.x - from.x) * t;
@ -76,7 +94,7 @@ export async function fakeType(
delayMs: number
): Promise<void> {
const delay = delayMs * RECORD_SCALE;
const steps = Math.min(6, text.length);
const steps = text.length;
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
await page.evaluate(
@ -121,14 +139,13 @@ export async function smoothDragSliderThumb(
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
await page.mouse.down();
// Keep the drag to a few pointer updates. The map will redraw after commit;
// asking React/deck.gl for dozens of intermediate travel-time states is what
// made previous renders crawl and look stuttery.
// The user explicitly prefers a longer render over stepped motion, so use
// enough real pointer updates for the thumb itself to read as continuous.
await smoothMove(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs, stepBudgetMs: 360 }
{ durationMs, realMouse: true, stepBudgetMs: 135 }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };

View file

@ -1,241 +1,23 @@
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import {
APP_URL,
AUTH_STATE_PATH,
CAPTURE_SCALE,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
MAX_DURATION_S,
OUTPUT_FPS,
OUTPUT_DIR,
PRELOAD_POSTCODE,
RECORD_SCALE,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
VIDEO_SIZE,
VIEWPORT,
WEBM_BITRATE,
} from './config.js';
import { installCursor, installZoomWrapper } from './dom.js';
import {
preZoomToAiBox,
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
import { sleep } from './motion.js';
/**
* Stub the AI endpoint. The real backend calls Gemini and takes 25s; for a
* 15-second video we want sub-second response so the map reacts crisply with
* the typed prompt still on screen. Returning canned filters also makes every
* recording bit-identical.
*
* The shape MUST match what useAiFilters expects (filters, travel_time_filters,
* notes, match_count) see frontend/src/hooks/useAiFilters.ts.
*/
async function stubAiFilters(page: import('playwright').Page) {
await page.route('**/api/ai-filters', async (route) => {
// Small delay so the loading indicator is visible (looks like real AI work).
await new Promise((r) => setTimeout(r, 180));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
notes: '',
match_count: 1247,
}),
});
});
}
async function stubExport(page: import('playwright').Page) {
await page.route('**/api/export?**', async (route) => {
await new Promise((r) => setTimeout(r, 160));
await route.fulfill({
status: 200,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers: {
'content-disposition': 'attachment; filename="perfect-postcode-export.xlsx"',
},
body: Buffer.from('Perfect Postcode demo export\n'),
});
});
}
function addInitialTravelTimeParams(params: URLSearchParams) {
for (const tt of STUBBED_TRAVEL_TIME_FILTERS) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}
}
import { AUTH_STATE_PATH, OUTPUT_DIR } from './config.js';
import { launchRecordingBrowser } from './browser.js';
import { installDemoRoutes } from './routes.js';
import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js';
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(
`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first to log in once.`
);
console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`);
process.exit(1);
}
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
// Headless Chromium otherwise loses the WebGL context mid-render when
// deck.gl pushes large buffers; SwiftShader is software GL but stable.
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
// Lift Chromium's animation/raster rate caps so RECORD_SCALE actually
// gets us extra frames per second of wall time. Without these, Chromium
// throttles offscreen rendering and the slow-down is wasted.
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
],
});
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: CAPTURE_SCALE,
recordVideo: { dir: OUTPUT_DIR, size: VIDEO_SIZE },
});
// Vite's dev server pushes HMR updates over a "vite-hmr" WebSocket. If a
// module isn't accept-marked the client triggers a FULL page reload — which
// mid-recording resets the React tree and re-shows "Connecting to server…".
// Disable the client-side HMR socket entirely.
await context.addInitScript(() => {
// Block the vite-hmr WebSocket so HMR push messages never arrive.
const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, {
construct(target, args) {
const url = String(args[0] ?? '');
const proto = (args[1] as string | string[] | undefined) ?? '';
const protoStr = Array.isArray(proto) ? proto.join(',') : proto;
if (
protoStr.includes('vite-hmr') ||
protoStr.includes('webpack') ||
url.includes('/ws') ||
url.includes('sockjs-node')
) {
const fake = new EventTarget() as WebSocket;
Object.defineProperties(fake, {
readyState: { value: RealWS.CLOSED },
url: { value: url },
protocol: { value: '' },
extensions: { value: '' },
bufferedAmount: { value: 0 },
binaryType: { value: 'blob', writable: true },
});
fake.send = () => {};
fake.close = () => {
fake.dispatchEvent(new Event('close'));
};
queueMicrotask(() => fake.dispatchEvent(new Event('close')));
return fake;
}
return Reflect.construct(target, args);
},
});
// Belt-and-braces: even if an HMR push slips through (e.g. via a different
// transport in a later Vite version), neutralize the full-reload fallback.
const noop = () => {};
Object.defineProperty(window.location, 'reload', { value: noop, configurable: true });
// Stop runtime errors from reaching Vite's <vite-error-overlay>. We're
// recording against a dev server for fast iteration; in prod the overlay
// wouldn't exist either way. A stray deck.gl layer-pipeline error covering
// the dashboard ruins the take, so we eat the error before Vite can see it.
// capture=true → our handler runs before Vite's at the document level.
window.addEventListener(
'error',
(e) => {
e.stopImmediatePropagation();
},
true
);
window.addEventListener(
'unhandledrejection',
(e) => {
e.stopImmediatePropagation();
},
true
);
// CSS fallback covering all the bundlers' diagnostic overlays. Vite
// ships <vite-error-overlay>, webpack v5+ uses <wds-overlay> (shadow DOM)
// or <div id="webpack-dev-server-client-overlay">, webpack v4 injects an
// iframe. Compilation warnings surface as a top-level red banner that
// occludes the dashboard.
const styleEl = document.createElement('style');
styleEl.textContent = `
vite-error-overlay,
wds-overlay,
#webpack-dev-server-client-overlay,
#webpack-dev-server-client-overlay-div,
iframe[src*="webpack-dev-server"],
iframe[id*="webpack"],
[id*="webpack-dev-server-client"],
[class*="error-overlay"],
[class*="webpack-error"] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
`;
(document.head ?? document.documentElement).appendChild(styleEl);
// Belt-and-braces: a MutationObserver kills any newly-injected overlay
// root by name. Webpack v5 reinjects on each warning batch, so a static
// CSS rule alone occasionally races a brief flash of the banner.
const killOverlay = (node: Element) => {
const tag = node.tagName?.toLowerCase();
const id = (node as HTMLElement).id?.toLowerCase() ?? '';
if (
tag === 'vite-error-overlay' ||
tag === 'wds-overlay' ||
id.includes('webpack-dev-server-client') ||
id.includes('webpack-error')
) {
(node as HTMLElement).remove();
}
};
const obs = new MutationObserver((muts) => {
for (const m of muts)
m.addedNodes.forEach((n) => {
if (n.nodeType === 1) killOverlay(n as Element);
});
});
if (document.body) obs.observe(document.body, { childList: true, subtree: true });
else
document.addEventListener('DOMContentLoaded', () =>
obs.observe(document.body, { childList: true, subtree: true })
);
});
const { browser, context } = await launchRecordingBrowser();
const page = await context.newPage();
const recordedVideo = page.video();
// recordVideo starts the moment the page is created. We want the final clip
// to begin once we're zoomed on the AI prompt and ready to type — NOT
// include navigation, sidebar mount, or the AI button click. We track scene
// start vs record start and ffmpeg-trim post-hoc.
const recordStartMs = Date.now();
page.on('console', (m) => {
if (m.type() === 'error' || m.type() === 'warning') {
console.log(`[browser ${m.type()}] ${m.text()}`);
@ -251,87 +33,14 @@ async function main() {
const u = r.url();
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await stubAiFilters(page);
await stubExport(page);
// Pre-load with ?pc= so the dashboard auto-opens the right pane and
// flies to that postcode at zoom 16 (where postcode polygons render
// individually). The cluster click later in the scene becomes purely
// visual — the pane is already there, data already loaded.
const params = new URLSearchParams({
pc: PRELOAD_POSTCODE,
lat: String(INITIAL_MAP_VIEW.lat),
lon: String(INITIAL_MAP_VIEW.lon),
zoom: String(INITIAL_MAP_VIEW.zoom),
});
addInitialTravelTimeParams(params);
const url = `${APP_URL}${DASHBOARD_PATH}?${params}`;
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')
.waitFor({ state: 'visible', timeout: 15000 });
// Wait for the right pane to actually mount AND its content to render.
// Without this the AI scene's browser-side setInterval (fakeType) gets
// throttled by Chromium's scheduler under boot load (deck.gl uploads,
// Street View iframe, postcode geometry fetch) and typing stretches 3×.
try {
await page
.locator('[data-tutorial="right-pane"]')
.waitFor({ state: 'visible', timeout: 15000 });
// Either the area stats or the Street View embed indicates the pane
// has finished its first render pass.
await page
.locator('[data-tutorial="right-pane"] iframe, [data-tutorial="right-pane"] canvas')
.first()
.waitFor({ state: 'attached', timeout: 2000 })
.catch(() => {});
} catch {
// Pane didn't appear (postcode may not exist on this stack); proceed
// anyway — scenes still work, just with no pane content.
console.log('[render] right-pane preload did not mount; continuing');
}
// Final settle. The dashboard's flyTo to the preloaded postcode runs a
// ~1.5s maplibre animation; deck.gl's hexagon/postcode buffers upload
// through the same window. We wait long enough that all of this completes
// before scenes start, otherwise the AI scene's browser-side setInterval
// (fakeType) gets throttled to 200ms+ ticks.
await new Promise((r) => setTimeout(r, 1200));
// Wrapper must be installed BEFORE the cursor — the cursor is appended to
// <body> and must remain a sibling of the wrapper, not a descendant.
await installZoomWrapper(page);
await installCursor(page);
// Park cursor near the AI box (sidebar) at low-y so the first move is short.
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
// Pre-flight (NOT counted in scene wall time): expand the AI prompt and
// snap the wrapper to its zoomed-on-AI starting state.
await preZoomToAiBox(ctx);
await sleep(80);
const sceneStartMs = Date.now();
const t = (label: string, prev: number) => {
const now = Date.now();
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`);
return now;
};
let mark = sceneStartMs;
await sceneAiCloseUp(ctx); mark = t('AI close-up', mark);
await sceneZoomOutResults(ctx); mark = t('Zoom out', mark);
await sceneTravelTimeSlider(ctx); mark = t('TT slider', mark);
await sceneClusterClick(ctx); mark = t('Cluster click', mark);
await sceneExportAndOutro(ctx); mark = t('Export + outro', mark);
const sceneEndMs = Date.now();
await installDemoRoutes(page);
const ctx = await prepareTimeline(page);
const timeline = await runTimeline(ctx);
await page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
if (recordedVideo) {
await recordedVideo.saveAs(rawPath);
}
if (recordedVideo) await recordedVideo.saveAs(rawPath);
await context.close();
await browser.close();
@ -340,46 +49,10 @@ async function main() {
process.exit(1);
}
const trimmedPath = join(OUTPUT_DIR, 'recording.webm');
const sceneSpan = (sceneEndMs - sceneStartMs) / 1000;
const maxFinalDuration = Math.max(0.1, MAX_DURATION_S - 0.2);
// The trim window is in *recording wall time*, which is RECORD_SCALE× the
// visible duration. After ffmpeg setpts speeds it back up, the final clip
// will be exactly MAX_DURATION_S seconds (or sceneSpan/RECORD_SCALE if shorter).
const wallCap = maxFinalDuration * RECORD_SCALE;
const trimEnd = (sceneEndMs - recordStartMs) / 1000;
const wallDuration = Math.min(sceneSpan, wallCap);
const trimStart = trimEnd - wallDuration;
const finalDuration = wallDuration / RECORD_SCALE;
if (sceneSpan > wallCap) {
console.log(
`Scene wall time was ${sceneSpan.toFixed(2)}s (cap ${wallCap.toFixed(2)}s at scale ${RECORD_SCALE}); trimming to last ${maxFinalDuration.toFixed(2)}s (anchored to outro).`
);
}
// Trim + speed-up in one pass.
// - -ss + -t: trim window in raw recording's wall time.
// - setpts=PTS/RECORD_SCALE: speed up the clip back to "human time". Each
// raw frame plays for 1/N as long → we get N× the effective fps for free,
// no synthetic interpolation needed.
// - fps=OUTPUT_FPS gives the MP4 encoder a stable cadence; with the default
// RECORD_SCALE=2 this matches the real captured cadence (25fps × 2).
execSync(
`ffmpeg -y -ss ${trimStart.toFixed(3)} -i "${rawPath}" -t ${wallDuration.toFixed(3)} ` +
`-vf "setpts=PTS/${RECORD_SCALE},fps=${OUTPUT_FPS},trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS" -fps_mode cfr ` +
`-r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
`"${trimmedPath}"`,
{ stdio: 'inherit' }
);
// Drop the untrimmed file once we've extracted the scenes.
try {
statSync(rawPath) && renameSync(rawPath, rawPath + '.untrimmed');
} catch {
/* ignore */
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
trimRecording(rawPath, join(OUTPUT_DIR, 'recording.webm'), {
recordStartMs,
...timeline,
});
console.log('Run "npm run encode" to produce output/recording.mp4');
}

58
video/src/routes.ts Normal file
View file

@ -0,0 +1,58 @@
import type { Page } from 'playwright';
import {
APP_URL,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
} from './config.js';
export async function installDemoRoutes(page: Page) {
await Promise.all([stubAiFilters(page), stubExport(page)]);
}
export function dashboardUrl(): string {
const params = new URLSearchParams({
lat: String(INITIAL_MAP_VIEW.lat),
lon: String(INITIAL_MAP_VIEW.lon),
zoom: String(INITIAL_MAP_VIEW.zoom),
});
addInitialTravelTimeParams(params);
return `${APP_URL}${DASHBOARD_PATH}?${params}`;
}
async function stubAiFilters(page: Page) {
await page.route('**/api/ai-filters', async (route) => {
await new Promise((r) => setTimeout(r, 120));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
notes: '',
match_count: 1247,
}),
});
});
}
async function stubExport(page: Page) {
await page.route('**/api/export?**', async (route) => {
await new Promise((r) => setTimeout(r, 120));
await route.fulfill({
status: 200,
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers: {
'content-disposition': 'attachment; filename="perfect-postcode-export.xlsx"',
},
body: Buffer.from('Perfect Postcode demo export\n'),
});
});
}
function addInitialTravelTimeParams(params: URLSearchParams) {
for (const tt of STUBBED_TRAVEL_TIME_FILTERS) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}
}

View file

@ -13,13 +13,16 @@ import {
import {
clearVignette,
flashRect,
getDemoMapSettleVersion,
hideCaption,
scrollPaneTo,
showCaption,
showOutro,
visualClick,
waitForDemoMapSettled,
waitForCurrentDemoMapSettled,
waitForDemoSelectionReady,
zoomReset,
zoomTo,
zoomToInstant,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
@ -29,37 +32,36 @@ export interface SceneCtx {
}
/**
* Scene 1: open already zoomed in on the AI prompt card. Caption fades in,
* the user types their request, and the already-preloaded filters are revealed
* behind the zoomed wrapper. Keeping this beat visual avoids slow dev-server
* data refreshes eating the 15-second timeline.
*
* Pre-conditions (set up by record.ts before scene timer starts):
* - The AI box is already expanded (textarea visible, ready to focus).
* - The wrapper is already zoomed at AI_ZOOM_SCALE on the AI box centre.
* - The vignette is up.
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
* stubbed, while the map filters and right pane are loaded from the real app.
*/
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(160);
await sleep(180);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
await sleep(120);
await zoomToAiBox(page, 720);
await sleep(760);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
await sleep(160);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
const mapVersion = await getDemoMapSettleVersion(page);
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await sleep(160);
await waitForDemoMapSettled(page, 15000, mapVersion);
await showCaption(page, 'The filters are already live on the map.');
await sleep(360);
await sleep(560);
await hideCaption(page);
}
@ -72,10 +74,10 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 560);
await sleep(420);
await zoomReset(page, 860);
await sleep(980);
await hideCaption(page);
await sleep(80);
await sleep(180);
}
/**
@ -107,6 +109,7 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
const mapVersion = await getDemoMapSettleVersion(page);
ctx.cursor = await smoothDragSliderThumb(
page,
@ -114,87 +117,90 @@ export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
trackSelector,
ctx.cursor,
toFraction,
520
1180
);
await sleep(120);
await sleep(220);
await waitForDemoMapSettled(page, 16000, mapVersion);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(440);
await sleep(720);
await hideCaption(page);
await sleep(60);
await sleep(180);
}
/**
* Scene 4: zoom into a cluster of filtered postcodes (using deck.gl's own
* camera, via wheel events), click one, and as the right pane fills, pan
* the framing rightward while scrolling the pane content.
*
* Why two zoom mechanisms across this scene:
* - Pre-click: native deck.gl wheel-zoom. CSS-transforming the wrapper
* changes `canvas.getBoundingClientRect()` (scaled rect) without changing
* `canvas.width`. deck.gl's hit-test uses the rect for screenbuffer
* mapping, returns a partial picked object, and React re-renders mid-paint
* leaving a null layer reference that crashes `MapboxLayer.render`.
* Native wheel-zoom recomputes deck.gl's camera in-place; layers stay coherent.
* - Post-click: CSS transform to pan the framing rightward. By this point
* the postcode is selected and layers are stable, so the transform is safe.
* Scene 4: after the filtered result map is visible, zoom into Manchester,
* click a hexagon, then let the right pane open from that selection.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
await showCaption(page, 'Open one promising area and check the detail before shortlisting.');
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
// Click point: roughly map centre. After AI flew the camera to Manchester, this
// sits in the densely-filtered city core where hexagons reliably cover any
// pixel. Earlier iterations wheel-zoomed first to "feel cinematic", but
// that crossed the hexagon→postcode layer-swap threshold mid-flight and
// clicks landed in a layer gap (no pane opened).
const cluster = {
x: 360 + (viewport.width - 360) * 0.5,
y: viewport.height * 0.45,
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(70);
await sleep(220);
// The right pane was opened at page load via ?pc= — no need to drive a
// real selection through deck.gl's hit-test, which is flaky in headless
// Chromium. The mouse.click here is purely for the visible cursor ripple
// animation; the pane is already populated with real postcode data.
await page.mouse.click(cluster.x, cluster.y);
await sleep(130);
await zoomMapWithWheel(page, cluster);
// NOW zoom in toward the cluster, pan rightward to centre the right pane,
// and scroll the pane content — all in parallel. Layers are stable so the
// CSS transform is safe.
const rightShift = 240;
await Promise.all([
zoomTo(page, {
scale: 1.35,
focusX: cluster.x + rightShift,
focusY: cluster.y,
durationMs: 520,
}),
scrollPaneTo(page, '[data-tutorial="right-pane"]', 480),
]);
await sleep(320);
const clicked = await clickHexagon(page, cluster);
ctx.cursor = clicked;
await openDemoHexagon(page);
await page.locator('[data-tutorial="right-pane"]').waitFor({ state: 'visible', timeout: 5000 });
await waitForDemoSelectionReady(page, 16000);
await sleep(360);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(520);
await sleep(1000);
await hideCaption(page);
}
async function clickHexagon(
page: Page,
target: { x: number; y: number }
): Promise<{ x: number; y: number }> {
await visualClick(page, target);
await sleep(140);
return target;
}
async function zoomMapWithWheel(page: Page, target: { x: number; y: number }): Promise<void> {
await page.mouse.move(target.x, target.y);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await sleep(95);
}
await waitForCurrentDemoMapSettled(page, 16000);
await sleep(260);
}
async function openDemoHexagon(page: Page): Promise<void> {
const selected = await page.evaluate(
() =>
(
window as typeof window & {
__demoOpenBestHexagon?: () => string | null;
}
).__demoOpenBestHexagon?.() ?? null
);
if (!selected) throw new Error('Could not open a demo hexagon selection');
}
/** Export the current shortlist, then reveal the URL. */
export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, 460);
await sleep(240);
await zoomReset(page, 680);
await sleep(520);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
@ -202,34 +208,25 @@ export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
if (!box) throw new Error('Export button has no bounding box');
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 360 });
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
ctx.cursor = target;
await sleep(70);
await sleep(160);
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
await page.mouse.click(target.x, target.y);
await flashRect(page, box);
await sleep(280);
await sleep(680);
await hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2400);
await sleep(2200);
}
/**
* Helper used by record.ts: after navigation but BEFORE the scene timer
* starts, click the AI-prompt button so its textarea is mounted, then snap
* the wrapper to its zoomed-on-AI starting state.
*
* Splitting this out keeps the scene timer honest: the textarea's mount
* animation and the zoom snap don't eat into the 15s budget.
*/
export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
/** Open the AI prompt before the timed scene starts. */
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
// Open the AI prompt. The collapsed state shows a single button; clicking
// it expands the form and reveals the textarea.
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
@ -247,13 +244,13 @@ export async function preZoomToAiBox(ctx: SceneCtx): Promise<void> {
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
}
// Snap-zoom to the AI card centre. The recording opens already zoomed in
// — there's no awkward "from 1× to 2.4×" intro animation.
async function zoomToAiBox(page: Page, durationMs: number): Promise<void> {
const aiCard = page.locator('[data-tutorial="ai-filters"]');
const cardBox = await aiCard.boundingBox();
if (!cardBox) throw new Error('AI card has no bounding box');
const focusX = cardBox.x + cardBox.width / 2;
const focusY = cardBox.y + cardBox.height / 2;
await zoomToInstant(page, { scale: AI_ZOOM_SCALE, focusX, focusY });
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
}

58
video/src/timeline.ts Normal file
View file

@ -0,0 +1,58 @@
import type { Page } from 'playwright';
import { installCursor, installZoomWrapper, waitForCurrentDemoMapSettled } from './dom.js';
import { sleep } from './motion.js';
import { dashboardUrl } from './routes.js';
import {
prepareAiBox,
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
export interface TimelineResult {
sceneStartMs: number;
sceneEndMs: number;
}
export async function prepareTimeline(page: Page): Promise<SceneCtx> {
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
await page
.locator('[data-tutorial="ai-filters"]')
.waitFor({ state: 'visible', timeout: 15000 });
await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 });
await waitForCurrentDemoMapSettled(page, 15000);
await new Promise((r) => setTimeout(r, 400));
await installZoomWrapper(page);
await installCursor(page);
const ctx: SceneCtx = { page, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await prepareAiBox(ctx);
await sleep(80);
return ctx;
}
export async function runTimeline(ctx: SceneCtx): Promise<TimelineResult> {
const sceneStartMs = Date.now();
let mark = sceneStartMs;
mark = await runScene('AI close-up', mark, () => sceneAiCloseUp(ctx));
mark = await runScene('Zoom out', mark, () => sceneZoomOutResults(ctx));
mark = await runScene('TT slider', mark, () => sceneTravelTimeSlider(ctx));
mark = await runScene('Cluster click', mark, () => sceneClusterClick(ctx));
mark = await runScene('Export + outro', mark, () => sceneExportAndOutro(ctx));
return { sceneStartMs, sceneEndMs: mark };
}
async function runScene(label: string, prev: number, scene: () => Promise<void>): Promise<number> {
await scene();
const now = Date.now();
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`);
return now;
}

88
video/src/verify.ts Normal file
View file

@ -0,0 +1,88 @@
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { MAX_DURATION_S, MIN_DURATION_S, OUTPUT_FPS, OUTPUT_DIR, VIDEO_SIZE } from './config.js';
interface Probe {
streams?: {
width?: number;
height?: number;
avg_frame_rate?: string;
r_frame_rate?: string;
}[];
format?: {
duration?: string;
size?: string;
};
}
function fail(message: string): never {
console.error(`[verify] FAIL: ${message}`);
process.exit(1);
}
function parseRate(rate: string | undefined): number {
if (!rate) return 0;
const [num, den] = rate.split('/').map(Number);
if (!num || !den) return Number(rate) || 0;
return num / den;
}
function probe(path: string): Probe {
const raw = execFileSync(
'ffprobe',
[
'-v',
'error',
'-select_streams',
'v:0',
'-show_entries',
'stream=width,height,r_frame_rate,avg_frame_rate',
'-show_entries',
'format=duration,size',
'-of',
'json',
path,
],
{ encoding: 'utf8' }
);
return JSON.parse(raw) as Probe;
}
function verifyVideo(path: string) {
if (!existsSync(path)) fail(`${path} is missing`);
if (statSync(path).size === 0) fail(`${path} is empty`);
const data = probe(path);
const stream = data.streams?.[0];
if (!stream) fail(`${path} has no video stream`);
const duration = Number(data.format?.duration ?? 0);
const fps = parseRate(stream.avg_frame_rate || stream.r_frame_rate);
if (stream.width !== VIDEO_SIZE.width || stream.height !== VIDEO_SIZE.height) {
fail(`${path} is ${stream.width}x${stream.height}, expected ${VIDEO_SIZE.width}x${VIDEO_SIZE.height}`);
}
if (duration < MIN_DURATION_S || duration > MAX_DURATION_S) {
fail(
`${path} duration is ${duration.toFixed(2)}s, expected ${MIN_DURATION_S}-${MAX_DURATION_S}s`
);
}
if (Math.abs(fps - OUTPUT_FPS) > 0.1) {
fail(`${path} is ${fps.toFixed(2)}fps, expected ${OUTPUT_FPS}fps`);
}
console.log(
`[verify] ${path}: ${stream.width}x${stream.height}, ${duration.toFixed(2)}s, ${fps.toFixed(2)}fps`
);
}
function verifyImage(path: string) {
if (!existsSync(path)) fail(`${path} is missing`);
if (statSync(path).size === 0) fail(`${path} is empty`);
console.log(`[verify] ${path}: ${statSync(path).size} bytes`);
}
const videoPath = process.argv[2] ?? `${OUTPUT_DIR}/recording.mp4`;
const posterPath = process.argv[3] ?? (process.argv[2] ? undefined : `${OUTPUT_DIR}/poster.jpg`);
verifyVideo(videoPath);
if (posterPath) verifyImage(posterPath);

58
video/src/video.ts Normal file
View file

@ -0,0 +1,58 @@
import { execSync } from 'node:child_process';
import { renameSync, statSync } from 'node:fs';
import {
MAX_DURATION_S,
OUTPUT_FPS,
RECORD_SCALE,
VIDEO_SIZE,
WEBM_BITRATE,
} from './config.js';
const LEAD_IN_S = 0.12;
export function trimRecording(
rawPath: string,
trimmedPath: string,
times: { recordStartMs: number; sceneStartMs: number; sceneEndMs: number }
) {
const sceneSpan = (times.sceneEndMs - times.sceneStartMs) / 1000;
const trimStart = Math.max(
0,
(times.sceneStartMs - times.recordStartMs) / 1000 - LEAD_IN_S * RECORD_SCALE
);
const trimEnd = (times.sceneEndMs - times.recordStartMs) / 1000;
const wallDuration = trimEnd - trimStart;
const finalDuration = wallDuration / RECORD_SCALE;
if (finalDuration > MAX_DURATION_S) {
console.log(
`Scene output duration is ${finalDuration.toFixed(2)}s (guard ${MAX_DURATION_S.toFixed(2)}s); keeping the full take.`
);
}
const filter =
`trim=start=${trimStart.toFixed(3)}:duration=${wallDuration.toFixed(3)},` +
`setpts=(PTS-STARTPTS)/${RECORD_SCALE},fps=${OUTPUT_FPS},` +
`trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS`;
// Keep trimming inside the filter graph: it is frame-accurate for WebM
// without the keyframe leakage of input seeking or the post-speedup timing
// ambiguity of output seeking.
execSync(
`ffmpeg -y -i "${rawPath}" -vf "${filter}" ` +
`-fps_mode cfr -r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
`"${trimmedPath}"`,
{ stdio: 'inherit' }
);
try {
statSync(rawPath);
renameSync(rawPath, rawPath + '.untrimmed');
} catch {
/* ignore */
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${(sceneSpan / RECORD_SCALE).toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
}