Add video
This commit is contained in:
parent
589de0c5ac
commit
7c36cbfdd4
18 changed files with 2292 additions and 333 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
308
video/src/dom.ts
308
video/src/dom.ts
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
121
video/src/pb-admin.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 0–120, 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 screen→buffer
|
||||
* 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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue