Add video

This commit is contained in:
Andras Schmelczer 2026-05-05 22:15:29 +01:00
parent 589de0c5ac
commit 7c36cbfdd4
18 changed files with 2292 additions and 333 deletions

View file

@ -6,10 +6,11 @@
"type": "module",
"scripts": {
"build": "tsc",
"bootstrap-admin": "tsc && node dist/pb-admin.js",
"setup-auth": "tsc && node dist/auth.js",
"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 16 -preset slow -movflags +faststart output/recording.mp4",
"encode": "ffmpeg -y -i output/recording.webm -c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast -movflags +faststart output/recording.mp4",
"render": "./render.sh"
},
"dependencies": {

View file

@ -19,10 +19,24 @@ set -euo pipefail
APP_URL="${APP_URL:-http://host.docker.internal:3001}"
PB_URL="${PB_URL:-http://host.docker.internal:8090}"
API_URL="${API_URL:-http://host.docker.internal:8001}"
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:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-8}"
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
@ -107,6 +121,7 @@ fi
if [ "$need_auth" = "1" ]; then
say "Minting fresh auth.json (user: $PB_EMAIL)"
PB_URL="$PB_URL" PB_EMAIL="$PB_EMAIL" PB_PASSWORD="$PB_PASSWORD" \
PB_ADMIN_EMAIL="$PB_ADMIN_EMAIL" PB_ADMIN_PASSWORD="$PB_ADMIN_PASSWORD" \
APP_URL="$APP_URL" \
node dist/auth.js
else
@ -119,7 +134,9 @@ 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" node dist/record.js
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
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
@ -132,19 +149,54 @@ if [ "$DO_ENCODE" = "1" ]; then
fi
say "Encoding to MP4"
ffmpeg -y -loglevel warning -i output/recording.webm \
-c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
output/recording.mp4
# Poster: a single high-quality JPEG extracted from a representative
# moment in the output timeline. Used as the homepage <video poster=...>,
# which is what the visitor sees before pressing play.
# - -ss AFTER -i = output-side seek, frame-accurate (input-side seek
# would land on the nearest keyframe, drifting back up to ~2s).
# - -update 1 tells ffmpeg the output is a single image, not a sequence.
# - -q:v 2 = high JPEG quality (~95%); poster file is ~120KB at 1080p.
say "Extracting poster frame at ${POSTER_TIME_S}s"
ffmpeg -y -loglevel warning -i output/recording.mp4 -ss "$POSTER_TIME_S" \
-frames:v 1 -update 1 -q:v 2 \
output/poster.jpg
fi
# -- publish to homepage ------------------------------------------------------
# Only publish when we did the encode (otherwise we'd be copying a stale
# mp4 next to a fresh webm). --no-encode skips this whole block.
if [ "$DO_ENCODE" = "1" ]; then
if [ ! -d "$PUBLISH_DIR" ]; then
say "Creating $PUBLISH_DIR"
mkdir -p "$PUBLISH_DIR"
fi
say "Publishing to $PUBLISH_DIR"
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
fi
# -- report -------------------------------------------------------------------
say "Done"
if command -v ffprobe >/dev/null 2>&1; then
for f in output/recording.webm output/recording.mp4; do
for f in output/recording.webm output/recording.mp4 output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"; do
[ -f "$f" ] || continue
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
case "$f" in
*.mp4|*.webm)
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
;;
*)
printf ' %s %s bytes\n' "$f" "$size"
;;
esac
done
else
ls -la output/recording.* 2>/dev/null || true
ls -la output/recording.* output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg" 2>/dev/null || true
fi

View file

@ -1,6 +1,6 @@
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';
import { APP_URL, AUTH_STATE_PATH } from './config.js';
import { ensureRecorderAdminUser } from './pb-admin.js';
/**
* Auth setup. Two modes:
@ -26,6 +26,10 @@ async function programmatic() {
const email = process.env.PB_EMAIL!;
const password = process.env.PB_PASSWORD!;
if (process.env.PB_BOOTSTRAP_ADMIN !== '0') {
await ensureRecorderAdminUser();
}
// Driving the login through the app itself ensures the PocketBase SDK's
// LocalAuthStore sees the token via its own write path. Hand-writing
// localStorage["pb_auth"] sometimes races with the SDK's module-time read.

View file

@ -7,44 +7,98 @@ export const OUTPUT_DIR = 'output';
const aspect = process.env.ASPECT ?? '16x9';
export const VIEWPORT =
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1.5));
export const VIDEO_SIZE = {
width: VIEWPORT.width,
height: VIEWPORT.height,
};
export const WEBM_BITRATE = process.env.WEBM_BITRATE ?? (CAPTURE_SCALE > 1 ? '18M' : '8M');
// Cold-open prompt. Punchy version of the user's intent — short enough that
// the typing animation fits in the AI scene without throttling pushing past
// the trim window. Each char costs ~80ms wall under boot CPU load.
export const PROMPT_TEXT =
process.env.PROMPT_TEXT ?? 'Near Kings Cross, EPC C+, under £600k';
process.env.PROMPT_TEXT ?? 'Flats or terraces <£450k, 35 min to Manchester, low crime';
// Filter the AI stub will "return". Keys must match real feature names from
// /api/features. Pulled from the running server's schema.
// Filters returned by the AI stub. Keys MUST match real feature names from
// /api/features (verified against the running server's schema).
export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = {
'Estimated current price': [0, 600000],
'Number of bedrooms & living rooms': [4, 6],
'Property type': ['Detached', 'Semi-Detached', 'Terraced'],
'Distance to nearest train or tube station (km)': [0, 1.0],
'Property type': ['Flats/Maisonettes', 'Terraced'],
'Estimated current price': [175000, 450000],
'Serious crime per 1k residents (avg/yr)': [0, 55],
'Noise (dB)': [50, 68],
};
// Slider we'll drag in scene 3. Must be a numeric (range) feature, and must
// already be in STUBBED_FILTERS so the card is mounted by the time we drag.
export const DRAG_FILTER_NAME =
process.env.DRAG_FILTER_NAME ?? 'Estimated current price';
// Fraction of the track to drag the right thumb to (0..1 from the left).
export const DRAG_TO_FRACTION = 0.55;
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response.
export const STUBBED_TRAVEL_TIME_FILTERS: {
mode: 'transit' | 'car' | 'bicycle' | 'walking';
slug: string;
label: string;
min?: number;
max?: number;
}[] = [
{
mode: 'transit',
slug: 'manchester',
label: 'Manchester city centre',
max: 35,
},
];
// London-ish view used for the cold open.
export const COLD_OPEN_VIEW = '#lat=51.535&lon=-0.105&zoom=11';
// The travel-time card we'll drag manually after AI applies. The 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;
// Hard cap on the trimmed output. Scene-time overhead (CDP roundtrips,
// boundingBox calls, layout settling) varies run-to-run, so we trim to a
// deterministic length even if total scene wall time exceeds it.
// Cold-open zoom: how aggressively to magnify the AI box.
// 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 = {
lat: 53.4795,
lon: -2.2451,
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';
// 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 and animations by this factor while recording,
// then speed the output back up by the same factor in ffmpeg. The visible
// animation speed in the final video is unchanged, but each visual frame had
// N× more wall time to render → fewer dropped frames, smoother motion.
//
// 1 = no slow-down (choppy on software GL)
// 2 = double recording length, ~2× more unique frames in output (recommended)
// 3-4 = even smoother, slower to produce; diminishing returns past 4
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3));
// 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. We force ffmpeg to interpolate up to this
// rate so the speed-up doesn't leave gaps.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 60);
// 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.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);
// Brand strings for the outro card.
export const BRAND_NAME = 'Perfect Postcode';
export const BRAND_TAGLINE = 'Find where you actually want to live.';
export const BRAND_URL = 'https://perfect-postcode.co.uk';

View file

@ -43,6 +43,20 @@ export async function installCursor(page: Page): Promise<void> {
0% { width: 0; height: 0; opacity: 1; }
100% { width: 64px; height: 64px; opacity: 0; }
}
.__demo-focus-pulse {
position: fixed;
pointer-events: none;
z-index: 2147483644;
border: 2px solid rgba(94, 234, 212, 0.95);
border-radius: 10px;
box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45), 0 18px 44px rgba(15, 23, 42, 0.35);
animation: __demo-focus-pulse 900ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes __demo-focus-pulse {
0% { opacity: 0; transform: scale(0.92); box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.45); }
20% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.15); box-shadow: 0 0 0 22px rgba(20, 184, 166, 0); }
}
#__demo-vignette {
position: fixed; inset: 0;
@ -57,22 +71,25 @@ export async function installCursor(page: Page): Promise<void> {
#__demo-caption {
position: fixed;
left: 50%;
bottom: 7%;
transform: translate(-50%, 24px);
padding: 14px 22px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.78);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
bottom: 6.5%;
transform: translate(-50%, 28px);
width: max-content;
max-width: min(1160px, 78vw);
padding: 18px 28px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.84);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: #f0fdfa;
font: 500 22px/1.2 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.01em;
box-shadow: 0 14px 40px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.08);
font: 650 32px/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0;
text-align: center;
box-shadow: 0 18px 54px rgba(0,0,0,0.38), inset 0 0 0 1px rgba(255,255,255,0.1);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease-out, transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
white-space: normal;
}
#__demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
@ -84,26 +101,54 @@ export async function installCursor(page: Page): Promise<void> {
pointer-events: none;
transition: background 700ms ease-out;
}
#__demo-outro.visible { background: rgba(2, 6, 23, 0.78); backdrop-filter: blur(8px); }
#__demo-outro .card {
#__demo-outro.visible {
background:
radial-gradient(circle at 50% 38%, rgba(20, 184, 166, 0.28), transparent 34%),
rgba(2, 6, 23, 0.84);
backdrop-filter: blur(10px);
}
#__demo-outro-card {
text-align: center;
color: white;
opacity: 0;
transform: translateY(12px) scale(0.98);
transition: opacity 700ms ease-out 120ms, transform 700ms cubic-bezier(0.22,1,0.36,1) 120ms;
opacity: 1;
transform: translateY(0) scale(1);
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 .card { opacity: 1; transform: translateY(0) scale(1); }
#__demo-outro h1 {
font: 700 64px/1.05 ui-sans-serif, system-ui, sans-serif;
margin: 0 0 12px;
@keyframes __demo-outro-pop {
0% { transform: translateY(10px) scale(0.985); }
100% { transform: translateY(0) scale(1); }
}
#__demo-outro-brand {
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
margin: 0 0 16px;
background: linear-gradient(90deg, #5eead4, #14b8a6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: -0.02em;
letter-spacing: 0;
}
#__demo-outro-tagline {
font: 500 28px/1.4 ui-sans-serif, system-ui, sans-serif;
color: #cbd5e1;
margin: 0 0 28px;
}
#__demo-outro-url {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 16px 24px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(94, 234, 212, 0.36);
box-shadow: 0 22px 60px rgba(0,0,0,0.32);
font: 700 34px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0;
color: #99f6e4;
}
#__demo-outro p { font: 400 24px/1.4 ui-sans-serif, system-ui, sans-serif; color: #cbd5e1; margin: 0 0 18px; }
#__demo-outro .url { font: 600 22px/1 ui-sans-serif, system-ui, sans-serif; color: #5eead4; }
`,
});
@ -175,6 +220,22 @@ export async function hideCaption(page: Page): Promise<void> {
});
}
export async function flashRect(
page: Page,
rect: { x: number; y: number; width: number; height: number }
): Promise<void> {
await page.evaluate((r) => {
const el = document.createElement('div');
el.className = '__demo-focus-pulse';
el.style.left = `${r.x - 6}px`;
el.style.top = `${r.y - 6}px`;
el.style.width = `${r.width + 12}px`;
el.style.height = `${r.height + 12}px`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 950);
}, rect);
}
export async function showOutro(
page: Page,
brand: string,
@ -183,19 +244,206 @@ export async function showOutro(
): Promise<void> {
await page.evaluate(
({ brand, tagline, url }) => {
document.getElementById('__demo-caption')?.classList.remove('visible');
const el = document.createElement('div');
el.id = '__demo-outro';
el.className = 'visible';
el.innerHTML = `
<div class="card">
<h1>${brand}</h1>
<p>${tagline}</p>
<div class="url">${url}</div>
<div id="__demo-outro-card">
<div id="__demo-outro-brand">${brand}</div>
<div id="__demo-outro-tagline">${tagline}</div>
<div id="__demo-outro-url">${url}</div>
</div>`;
document.body.appendChild(el);
// Force reflow so the transition fires.
void el.offsetHeight;
el.classList.add('visible');
},
{ brand, tagline, url }
);
}
/**
* Wrap #root in a transformable div so we can CSS-zoom the entire app
* without dragging the cursor/caption/outro overlays along with it.
*
* Why a wrapper and not <body>: a transformed ancestor establishes a new
* containing block for `position: fixed` descendants meaning fixed
* overlays inside the transform get scaled too. By wrapping ONLY #root
* and leaving the overlays as siblings of the wrapper, the cursor stays
* at native size while the dashboard zooms behind it.
*/
export async function installZoomWrapper(page: Page): Promise<void> {
await page.addStyleTag({
content: `
html, body { background: #111827 !important; }
#__demo-backdrop {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 18% 16%, rgba(20, 184, 166, 0.32), transparent 26%),
radial-gradient(circle at 78% 20%, rgba(14, 165, 233, 0.2), transparent 24%),
linear-gradient(135deg, #0f172a 0%, #111827 46%, #1f2937 100%);
}
#__demo-zoom-wrap {
position: fixed; inset: 0;
z-index: 1;
transform-origin: 0 0;
transform: translate(0px, 0px) scale(1);
will-change: transform;
overflow: hidden;
background: #f8fafc;
box-shadow: 0 36px 110px rgba(0,0,0,0.36);
}
#__demo-zoom-wrap::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.16);
}
`,
});
await page.evaluate(() => {
const root = document.getElementById('root');
if (!root) return;
if (document.getElementById('__demo-zoom-wrap')) return;
const backdrop = document.createElement('div');
backdrop.id = '__demo-backdrop';
document.body.insertBefore(backdrop, document.body.firstChild);
const wrap = document.createElement('div');
wrap.id = '__demo-zoom-wrap';
root.parentElement?.insertBefore(wrap, root);
wrap.appendChild(root);
});
}
/**
* Zoom the wrapper so that (focusX, focusY) in original CSS pixels ends up
* at the centre of the viewport at the given scale.
*
* Math: we use transform-origin (0,0) so a point (x,y) maps to
* (k·x + dx, k·y + dy)
* To put (focusX, focusY) at (W/2, H/2) we set
* dx = W/2 - k·focusX, dy = H/2 - k·focusY.
* This avoids the awkward double-application you get with non-zero origins.
*
* The transition is set inline so callers can pick a per-call duration
* without restyling. After this call the wrapper animates over `durationMs`;
* sleep that long to wait it out.
*/
export async function zoomTo(
page: Page,
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = 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, durationMs }) => {
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.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 }
);
}
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
await page.evaluate((durationMs) => {
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.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 }
);
}
/**
* Smoothly scroll the closest scrollable ancestor of `selector` to `top`.
* Uses the browser's native smooth-scroll (compositor-driven, doesn't fight
* the recorder for CPU). If nothing scrollable is found, no-ops.
*/
export async function scrollPaneTo(
page: Page,
selector: string,
top: number
): Promise<void> {
await page.evaluate(
({ selector, top }) => {
const el = document.querySelector(selector) as HTMLElement | null;
if (!el) return;
const findScrollable = (node: HTMLElement | null): HTMLElement | null => {
let n: HTMLElement | null = node;
while (n) {
const oy = getComputedStyle(n).overflowY;
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) return n;
n = n.parentElement;
}
return null;
};
// Look both inside (for the actual scroll container deeper in the tree)
// and outwards.
const inner =
Array.from(el.querySelectorAll<HTMLElement>('*')).find((n) => {
const oy = getComputedStyle(n).overflowY;
return (oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight;
}) ?? null;
const target = inner ?? findScrollable(el) ?? el;
target.scrollTo({ top, behavior: 'smooth' });
},
{ selector, top }
);
}

View file

@ -22,41 +22,52 @@ export const easeOutBack = (t: number): number => {
interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
/**
* 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
* 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;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
* The injected cursor follows via its mousemove listener no explicit visual sync needed.
* The injected cursor follows via its mousemove listener.
*
* Why no explicit sleep between steps: each `await page.mouse.move(...)` is a
* synchronous WebSocket round-trip to Chromium. Adding a setTimeout on top
* means the loop runs at `cdp_latency + sleepMs`, overshooting wallDuration
* by ~3×. We instead size `steps = wallDuration / CDP_MOVE_MS` so the loop's
* natural pace lands on the target wall duration.
*/
export async function smoothMove(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ durationMs = 600, ease = easeInOut }: MoveOptions = {}
{ durationMs = 600, ease = easeInOut, stepBudgetMs = CDP_MOVE_MS }: MoveOptions = {}
): Promise<void> {
// Step count scales with RECORD_SCALE so we get more cursor positions per
// unit of visible animation — each one is a chance for the renderer to
// sample. CDP roundtrips cap us at ~60 commands/s, so 60fps × RECORD_SCALE
// is the practical ceiling.
const fps = 60;
const wallDuration = durationMs * RECORD_SCALE;
const steps = Math.max(2, Math.round((wallDuration / 1000) * fps));
const stepWaitMs = wallDuration / steps;
const steps = Math.max(2, Math.min(28, 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;
const y = from.y + (to.y - from.y) * t;
await page.mouse.move(x, y);
// Use a non-scaling sleep here — we already factored RECORD_SCALE in.
await new Promise((r) => setTimeout(r, stepWaitMs));
}
}
/**
* "Fake" type: progressively set the textarea value from inside the browser,
* dispatching React-compatible input events. Looks identical to keyboard.type
* but runs in one CDP roundtrip instead of N (where N = char count). On a
* 37-char prompt this is ~1s instead of ~3s.
* "Fake" type: progressively set the textarea value, dispatching
* React-compatible input events. This is Node-driven instead of browser
* setInterval-driven because 4K software WebGL can starve page timers and
* stretch a two-second typing beat into a minute.
*/
export async function fakeType(
page: Page,
@ -64,34 +75,27 @@ export async function fakeType(
text: string,
delayMs: number
): Promise<void> {
// Scale browser-side typing by RECORD_SCALE too, so the typing animation
// has more wall time per character to render.
const scaledDelay = delayMs * RECORD_SCALE;
await page.evaluate(
({ selector, text, delayMs }) => {
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
if (!ta) throw new Error('textarea not found: ' + selector);
ta.focus();
// React tracks the textarea value by hooking the descriptor; we have to
// call the prototype setter directly so React sees the change.
const proto = Object.getPrototypeOf(ta);
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (!setValue) throw new Error('no value setter on textarea');
return new Promise<void>((resolve) => {
let i = 0;
const id = window.setInterval(() => {
i += 1;
setValue.call(ta, text.slice(0, i));
ta.dispatchEvent(new Event('input', { bubbles: true }));
if (i >= text.length) {
window.clearInterval(id);
resolve();
}
}, delayMs);
});
},
{ selector, text, delayMs: scaledDelay }
);
const delay = delayMs * RECORD_SCALE;
const steps = Math.min(6, text.length);
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
await page.evaluate(
({ selector, value }) => {
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
if (!ta) throw new Error('textarea not found: ' + selector);
ta.focus();
// React tracks the textarea value by hooking the descriptor; we have to
// call the prototype setter directly so React sees the change.
const proto = Object.getPrototypeOf(ta);
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (!setValue) throw new Error('no value setter on textarea');
setValue.call(ta, value);
ta.dispatchEvent(new Event('input', { bubbles: true }));
},
{ selector, value: text.slice(0, end) }
);
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
}
}
/**
@ -104,7 +108,7 @@ export async function smoothDragSliderThumb(
trackSelector: string,
fromCursor: { x: number; y: number },
toFraction: number,
durationMs = 1100
durationMs = 520
): Promise<{ x: number; y: number }> {
const thumbBox = await page.locator(thumbSelector).boundingBox();
const trackBox = await page.locator(trackSelector).boundingBox();
@ -115,13 +119,16 @@ export async function smoothDragSliderThumb(
const targetX = trackBox.x + trackBox.width * toFraction;
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 500 });
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.
await smoothMove(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs }
{ durationMs, stepBudgetMs: 360 }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };

121
video/src/pb-admin.ts Normal file
View file

@ -0,0 +1,121 @@
interface SuperuserAuthResponse {
token: string;
}
interface UserRecord {
id: string;
email: string;
is_admin?: boolean;
subscription?: string;
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
async function pbJson<T>(
url: string,
init: RequestInit,
label: string
): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`${label} ${res.status}: ${await res.text()}`);
}
return (await res.json()) as T;
}
async function superuserToken(pbUrl: string, email: string, password: string): Promise<string> {
const data = await pbJson<SuperuserAuthResponse>(
`${pbUrl}/api/collections/_superusers/auth-with-password`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: email, password }),
},
'PocketBase superuser auth'
);
return data.token;
}
async function findUser(pbUrl: string, token: string, email: string): Promise<UserRecord | null> {
const filter = `email="${email.replaceAll('"', '\\"')}"`;
const data = await pbJson<{ items: UserRecord[] }>(
`${pbUrl}/api/collections/users/records?filter=${encodeURIComponent(filter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } },
'PocketBase user lookup'
);
return data.items[0] ?? null;
}
function recorderUserBody(email: string, password: string): Record<string, unknown> {
return {
email,
emailVisibility: true,
verified: true,
password,
passwordConfirm: password,
is_admin: true,
subscription: 'licensed',
};
}
export async function ensureRecorderAdminUser(): Promise<void> {
const pbUrl = requireEnv('PB_URL').replace(/\/$/, '');
const email = requireEnv('PB_EMAIL');
const password = requireEnv('PB_PASSWORD');
const adminEmail = process.env.PB_ADMIN_EMAIL ?? process.env.POCKETBASE_ADMIN_EMAIL;
const adminPassword = process.env.PB_ADMIN_PASSWORD ?? process.env.POCKETBASE_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
throw new Error('PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD are required to bootstrap the recorder user');
}
const token = await superuserToken(pbUrl, adminEmail, adminPassword);
const existing = await findUser(pbUrl, token, email);
const body = recorderUserBody(email, password);
if (existing) {
await pbJson<UserRecord>(
`${pbUrl}/api/collections/users/records/${existing.id}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
'PocketBase recorder user update'
);
console.log(`Updated recorder admin user ${email}.`);
return;
}
await pbJson<UserRecord>(
`${pbUrl}/api/collections/users/records`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
'PocketBase recorder user create'
);
console.log(`Created recorder admin user ${email}.`);
}
async function main() {
await ensureRecorderAdminUser();
}
if (process.argv[1]?.endsWith('pb-admin.js')) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -1,26 +1,32 @@
import { chromium } from 'playwright';
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, renameSync, readdirSync, statSync } from 'node:fs';
import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
import { join } from 'node:path';
import {
APP_URL,
AUTH_STATE_PATH,
COLD_OPEN_VIEW,
CAPTURE_SCALE,
DASHBOARD_PATH,
INITIAL_MAP_VIEW,
MAX_DURATION_S,
OUTPUT_DIR,
OUTPUT_FPS,
OUTPUT_DIR,
PRELOAD_POSTCODE,
RECORD_SCALE,
STUBBED_FILTERS,
STUBBED_TRAVEL_TIME_FILTERS,
VIDEO_SIZE,
VIEWPORT,
WEBM_BITRATE,
} from './config.js';
import { installCursor } from './dom.js';
import { installCursor, installZoomWrapper } from './dom.js';
import {
sceneAiPrompt,
sceneColdOpen,
sceneOutro,
scenePropertyReveal,
sceneSliderControl,
preZoomToAiBox,
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
import { sleep } from './motion.js';
@ -30,17 +36,20 @@ import { sleep } from './motion.js';
* 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, 400));
await new Promise((r) => setTimeout(r, 180));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
filters: STUBBED_FILTERS,
travel_time_filters: [],
travel_time_filters: STUBBED_TRAVEL_TIME_FILTERS,
notes: '',
match_count: 1247,
}),
@ -48,6 +57,26 @@ async function stubAiFilters(page: import('playwright').Page) {
});
}
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}`);
}
}
async function main() {
if (!existsSync(AUTH_STATE_PATH)) {
console.error(
@ -82,8 +111,8 @@ async function main() {
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport: VIEWPORT,
deviceScaleFactor: 2,
recordVideo: { dir: OUTPUT_DIR, size: 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
@ -95,15 +124,30 @@ async function main() {
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')) {
return Object.assign(Object.create(RealWS.prototype), {
readyState: RealWS.CONNECTING,
send() {}, close() {},
addEventListener() {}, removeEventListener() {},
dispatchEvent: () => true,
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);
},
@ -113,13 +157,84 @@ async function main() {
// 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 page = await context.newPage();
const recordedVideo = page.video();
// recordVideo starts the moment the page is created. We want the final clip
// to begin at the cold-open scene, not include the navigation/settle phase.
// Track when the recording started and when the scenes start, so we can
// ffmpeg-trim post-hoc.
// 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') {
@ -137,70 +252,122 @@ async function main() {
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
await stubAiFilters(page);
await stubExport(page);
const url = `${APP_URL}${DASHBOARD_PATH}${COLD_OPEN_VIEW}`;
await page.goto(url, { waitUntil: 'networkidle' });
// 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 });
// Settle: deck.gl tiles, postcode aggregations, sidebar mount.
await sleep(800);
// 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 top-left so its first move (to the AI box) is visible.
const ctx: SceneCtx = { page, cursor: { x: 80, y: 90 } };
// 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();
await sceneColdOpen(ctx);
await sceneAiPrompt(ctx);
await sceneSliderControl(ctx);
await scenePropertyReveal(ctx);
await sceneOutro(ctx);
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 page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
if (recordedVideo) {
await recordedVideo.saveAs(rawPath);
}
await context.close();
await browser.close();
// Playwright names recordings by guid; rename the most recent one.
const files = readdirSync(OUTPUT_DIR)
.filter((f) => f.endsWith('.webm') && f.startsWith('page@'))
.map((f) => ({ f, t: statSync(join(OUTPUT_DIR, f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
if (!files[0]) {
if (!recordedVideo || !statSync(rawPath).size) {
console.error('no recorded webm found');
process.exit(1);
}
const rawPath = join(OUTPUT_DIR, files[0].f);
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.
const wallCap = MAX_DURATION_S * RECORD_SCALE;
// 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 ${MAX_DURATION_S}s (anchored to outro).`
`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 AND speed up AND interpolate to OUTPUT_FPS in one pass.
// 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".
// - minterpolate at fps=OUTPUT_FPS: synthesize intermediate frames so the
// sped-up output runs smoothly at 60fps even if raw was 25fps.
// - libvpx-vp9 with -deadline good gives a tight WebM that the encode step
// can re-mux to MP4 quickly.
// - 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},minterpolate=fps=${OUTPUT_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" ` +
`-r ${OUTPUT_FPS} ` +
`-c:v libvpx-vp9 -b:v 6M -deadline good -cpu-used 2 ` +
`-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' }
);
@ -210,7 +377,9 @@ async function main() {
} catch {
/* ignore */
}
console.log(`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s @ ${OUTPUT_FPS}fps, scale=${RECORD_SCALE})`);
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
console.log('Run "npm run encode" to produce output/recording.mp4');
}

View file

@ -1,14 +1,25 @@
import type { Page } from 'playwright';
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
DRAG_FILTER_NAME,
DRAG_TO_FRACTION,
TT_CARD_SELECTOR,
TT_DRAG_FROM_MIN,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import {
clearVignette,
flashRect,
hideCaption,
scrollPaneTo,
showCaption,
showOutro,
zoomReset,
zoomTo,
zoomToInstant,
} from './dom.js';
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
@ -17,109 +28,232 @@ export interface SceneCtx {
cursor: { x: number; y: number };
}
/** Cold open. Vignette fades; cursor parks at a "natural" rest position. */
export async function sceneColdOpen(ctx: SceneCtx): Promise<void> {
await clearVignette(ctx.page);
await ctx.page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await sleep(1100);
}
/**
* AI prompt scene: click the collapsed AI box, type the prompt, submit,
* watch the (stubbed) response apply.
* 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.
*/
export async function sceneAiPrompt(ctx: SceneCtx): Promise<void> {
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Describe the area you want.');
await clearVignette(page);
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(160);
const aiButton = page.locator('[data-tutorial="ai-filters"] button').first();
const btnBox = await aiButton.boundingBox();
if (!btnBox) throw new Error('AI button not found');
const target = { x: btnBox.x + btnBox.width / 2, y: btnBox.y + btnBox.height / 2 };
await smoothMove(page, ctx.cursor, target, { durationMs: 400 });
ctx.cursor = target;
await page.mouse.click(target.x, target.y);
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
await textarea.waitFor({ state: 'visible', timeout: 3000 });
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 14);
await sleep(120);
const taBox = await textarea.boundingBox();
if (taBox) {
const into = { x: taBox.x + 30, y: taBox.y + taBox.height / 2 };
await smoothMove(page, ctx.cursor, into, { durationMs: 220 });
ctx.cursor = into;
}
// fakeType runs the typing animation inside the browser to avoid CDP
// round-trip overhead per keystroke (which can quadruple total typing time).
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 35);
await sleep(180);
await page.keyboard.press('Enter');
await sleep(700);
const aiResponse = page
.waitForResponse(
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
{ timeout: 1800 }
)
.catch(() => null);
await page.evaluate(() => {
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
});
await aiResponse;
await showCaption(page, 'The filters are already live on the map.');
await sleep(360);
await hideCaption(page);
await sleep(150);
}
/**
* Slider scene: pan to a numeric filter's right thumb and drag it inward.
* The whole point: the user sees the map react in real time to a human action,
* driving home that AI sets a starting point but you stay in control.
* Scene 2: animate the wrapper back to scale 1 so the full dashboard is
* revealed. The map has already pan-flown to Manchester (MapPage's
* own flyTo fires when AI travel-time filters are applied), so the zoom-out
* lands on a useful, filtered view.
*/
export async function sceneSliderControl(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'You stay in control.');
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 560);
await sleep(420);
await hideCaption(page);
await sleep(80);
}
const card = page.locator(`[data-filter-name="${DRAG_FILTER_NAME}"]`);
await card.waitFor({ state: 'visible', timeout: 3000 });
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, so the 15-minute
* range crosses 15 step boundaries at our pace each one gets ~20+ recorded
* frames, so the thumb reads as a continuous slide rather than incremental.
*
* The card we drag (`tt_0`) only exists because the AI filter step inserted
* exactly one travel-time entry; if you change the AI stub's count, update
* the selector or this scene will time out.
*/
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(
page,
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
);
const card = page.locator(TT_CARD_SELECTOR);
await card.waitFor({ state: 'visible', timeout: 4000 });
await card.scrollIntoViewIfNeeded();
await sleep(120);
await sleep(60);
const thumbSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [role="slider"] >> nth=1`;
const trackSelector = `[data-filter-name="${DRAG_FILTER_NAME}"] [data-orientation="horizontal"] >> nth=0`;
// Two thumbs in a Radix range slider; the second one is the max.
const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`;
// Track is the first horizontal-orientation element inside the card.
const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`;
// Slider goes 0..120, target = 20 → fraction 0.166...
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
ctx.cursor = await smoothDragSliderThumb(
page,
thumbSelector,
trackSelector,
ctx.cursor,
DRAG_TO_FRACTION,
1100
toFraction,
520
);
await sleep(550);
await sleep(120);
await showCaption(page, 'The map redraws around the areas that still work.');
await sleep(440);
await hideCaption(page);
await sleep(150);
await sleep(60);
}
/** Property reveal: click a postcode on the map to open the side pane with charts. */
export async function scenePropertyReveal(ctx: SceneCtx): Promise<void> {
/**
* 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.
*/
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const target = {
x: 360 + (viewport.width - 360) * 0.55,
y: viewport.height * 0.5,
await showCaption(page, 'Open one promising area and check the detail before shortlisting.');
// 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,
};
await smoothMove(page, ctx.cursor, target, { durationMs: 500 });
ctx.cursor = target;
await smoothMove(page, ctx.cursor, cluster, { durationMs: 260 });
ctx.cursor = cluster;
await sleep(70);
await page.mouse.click(target.x, target.y);
await sleep(1300);
}
// 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);
/** Outro: full-screen logo card with brand + URL. */
export async function sceneOutro(ctx: SceneCtx): Promise<void> {
await showOutro(
ctx.page,
'Perfect Postcodes',
'Find where you actually want to live.',
'perfectpostcodes.com'
// 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);
await showCaption(
page,
'This is the useful pause: local stats, matching homes, and street context together.'
);
await sleep(1800);
await sleep(520);
await hideCaption(page);
}
/** 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);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
const box = await exportButton.boundingBox();
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 });
ctx.cursor = target;
await sleep(70);
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 hideCaption(page);
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
void download;
await sleep(2400);
}
/**
* 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> {
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 });
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
if (!(await textarea.isVisible().catch(() => false))) {
const aiButton = aiRoot.locator('button').first();
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
const btnBox = await aiButton.boundingBox();
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
}
if (!(await textarea.isVisible().catch(() => false))) {
await page.evaluate(() => {
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
});
}
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.
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 });
}