This commit is contained in:
Andras Schmelczer 2026-06-28 11:59:44 +01:00
parent f1601257c7
commit c2070693fb
68 changed files with 2305 additions and 212 deletions

View file

@ -8,7 +8,7 @@ import { ensureRecorderAdminUser } from './pb-admin.js';
* 1. Programmatic (preferred for CI / non-interactive runs): set
* LOGIN_EMAIL and LOGIN_PASSWORD env vars (the same email/password you'd
* type into the dashboard's login modal). We drive the actual login form
* in a headless browser same path a real user takes, no knowledge of
* in a headless browser: the same path a real user takes, no knowledge of
* the PocketBase REST endpoint required.
*
* 2. Interactive: no env vars, we open a headed browser, you log in by hand,
@ -49,7 +49,7 @@ async function programmatic() {
{ timeout: 15_000 }
);
// Skip the react-joyride product tour its spotlight overlay intercepts
// Skip the react-joyride product tour: its spotlight overlay intercepts
// pointer events and breaks the recording.
await page.evaluate(() => { localStorage.setItem('tutorial_completed', '1'); });

View file

@ -151,7 +151,7 @@ async function suppressDevServerNoise(context: BrowserContext) {
[class*="webpack-error"],
/* Recording-only: hide dashboard chrome that reads as noise/loading
on camera (the "Finding the Perfect Postcode" AI-status CTA). The
data attribute is inert in production only this injected rule
data attribute is inert in production: only this injected rule
targets it, and only during a recording. */
[data-video-hide] {
display: none !important;

View file

@ -7,7 +7,7 @@ function requiredEnv(name: string): string {
}
// Environment-only knobs. Per-storyboard tuning (aspect, fps, bitrate,
// voice, prompts, brand…) lives on the Storyboard object itself — see
// voice, prompts, brand…) lives on the Storyboard object itself. See
// src/storyboard.ts.
export const APP_URL = requiredEnv('APP_URL');
@ -20,5 +20,5 @@ export const OUTPUT_DIR = 'output';
// Frames of head-room kept in front of sceneStart when trimming. Shared by
// the video trim and the narration manifest so cue offsets line up with the
// trimmed timeline. Not tuned per storyboard same lead-in for any cut.
// trimmed timeline. Not tuned per storyboard: same lead-in for any cut.
export const LEAD_IN_S = 0.12;

View file

@ -102,7 +102,7 @@ export class DashboardRecorder {
/**
* Best-effort wait for tracked API traffic to go quiet (250ms of no
* pending requests and no loading indicator). Unlike waitForStable this
* never throws it simply returns at the deadline. Used before computing
* never throws: it simply returns at the deadline. Used before computing
* hexagon click targets so the projection runs against the response for
* the CURRENT viewport rather than one captured mid-animation.
*/
@ -133,7 +133,7 @@ export class DashboardRecorder {
// Empty responses don't replace the snapshot (parse* skips them), so a
// snapshot whose bounds differ from the latest REQUEST means the current
// view has zero matching features and we'd be projecting stale data.
// Surface that loudly it almost always means the storyboard's filters
// Surface that loudly: it almost always means the storyboard's filters
// emptied the area it zoomed into.
const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds;
if (snapshotBounds && this.lastRequestedMapBounds) {
@ -141,7 +141,7 @@ export class DashboardRecorder {
if (snapKey !== this.lastRequestedMapBounds) {
console.log(
`[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` +
`vs latest request ${this.lastRequestedMapBounds}) — the current view ` +
`vs latest request ${this.lastRequestedMapBounds}). The current view ` +
`likely has no matching features; clicks may land on empty map.`
);
}
@ -221,7 +221,7 @@ export class DashboardRecorder {
}
/**
* The pixel rect inside `mapBox` that's safe to click i.e. not under
* The pixel rect inside `mapBox` that's safe to click, i.e. not under
* the dashboard's left filters pane, right details pane, or (on mobile)
* the floating MobileBottomSheet. We detect the sheet via the only
* `section.rounded-t-2xl` in the DOM and treat its top as a hard

View file

@ -6,8 +6,8 @@ import type { AdScene, AdScenePanel } from './script.js';
* native cursor is hidden so what the viewer sees is entirely our element.
*
* Design choice: the cursor listens to mousemove rather than being driven from
* the Node side. That keeps a single source of truth Playwright's real mouse
* and the visual is pure CSS, animated by the browser's compositor.
* the Node side. That keeps a single source of truth (Playwright's real mouse),
* and the visual is pure CSS, animated by the browser's compositor.
*/
export async function installCursor(
page: Page,
@ -235,7 +235,7 @@ export async function installCursor(
letter-spacing: -0.01em;
color: #5eead4;
}
/* Tighter outro for vertical 9:16 the brand/url stack must fit
/* Tighter outro for vertical 9:16. The brand/url stack must fit
comfortably inside the platform-safe centre column. */
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
@ -270,7 +270,7 @@ export async function installCursor(
-webkit-backdrop-filter: blur(3px) saturate(0.92);
}
/*
* Transparent mode: no scrim, no blur the product stays fully
* Transparent mode: no scrim, no blur, so the product stays fully
* visible behind a floating kicker + title. Used by mid-cue hook
* stings ("Postcode polygraph") that should not occlude the demo.
*/
@ -475,7 +475,7 @@ export async function installCursor(
/*
* Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2.
* That means the AdScene CSS is sizing against a 540px-wide viewport, so
* a 64px title is ~12% of viewport width per line still big and bold,
* a 64px title is ~12% of viewport width per line, still big and bold,
* but no longer overflows the available space the way 82px did on the
* old 1080-wide ad config. Captions also re-anchor to the upper third
* via body.__demo-aspect-vertical.
@ -601,7 +601,7 @@ export async function clearVignette(page: Page): Promise<void> {
* Tag the document body with the aspect class the caption / overlay CSS keys
* off. Run once during recorder setup so every cue inherits the right
* positioning without per-call overrides. The body class is the cheapest
* stable signal the storyboard's `video.aspect` knows the truth and we
* stable signal. The storyboard's `video.aspect` knows the truth and we
* surface it once into the DOM.
*/
export async function setAspectClass(
@ -741,7 +741,7 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
img.className = '__ad-image';
// Skip crossOrigin so the request goes through as a vanilla
// image fetch and CORS headers on the Unsplash CDN are not
// required. We never read pixels back out display only.
// required. We never read pixels back out: display only.
img.referrerPolicy = 'no-referrer';
img.onerror = () => img.remove();
img.src = src;
@ -872,7 +872,7 @@ export async function showOutro(
* 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
* 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.

View file

@ -8,7 +8,7 @@ import { storyboards } from './storyboard.js';
* Emit per-storyboard narration scripts for the synth step.
*
* Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered
* narration list text + per-cue gaps + voice config without depending
* narration list (text + per-cue gaps + voice config) without depending
* on Playwright, the dashboard, or auth. Walk each storyboard's cues, write
* a flat manifest under `output/<name>/narration-script.json`, then write
* an index manifest at `output/storyboards.json` so render.sh knows which
@ -18,11 +18,11 @@ import { storyboards } from './storyboard.js';
* matches storyboard cues to measured durations by index.
*/
// Em/en-dashes and ellipses make Qwen3-TTS produce dramatic pauses, sighs,
// or audible breaths — the captions still render the original (unicode-rich)
// or audible breaths. The captions still render the original (unicode-rich)
// text from the storyboard; only the synth input is sanitised.
function normalizeForTts(text: string): string {
return text
.replace(/\s*[—–]\s*/g, ', ')
.replace(/\s*[\u2014\u2013]\s*/g, ', ')
.replace(/…/g, '.')
.replace(/\.{3,}/g, '.')
.replace(/\s{2,}/g, ' ')
@ -39,7 +39,7 @@ function emitScript(storyboard: Storyboard): string {
gapBeforeMs: cue.gapBeforeMs,
}));
// The voice block is consumed by tts/synth.py — see _resolve_reference and
// The voice block is consumed by tts/synth.py. See _resolve_reference and
// the cache check there for which fields invalidate cached audio.
const manifest = {
storyboard: storyboard.name,
@ -61,7 +61,7 @@ function emitScript(storyboard: Storyboard): string {
/**
* Validate every stubbed/initial filter name and travel-destination slug
* against the LIVE API. Wrong names don't error in the app they silently
* against the LIVE API. Wrong names don't error in the app: they silently
* no-op, the map never changes, and you only find out after a full render.
* Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh
* has already health-checked it by the time preflight runs).
@ -69,7 +69,7 @@ function emitScript(storyboard: Storyboard): string {
async function validateAgainstLiveApi(): Promise<void> {
const apiBase = process.env.API_URL ?? process.env.APP_URL;
if (!apiBase) {
console.warn('[preflight] no API_URL/APP_URL set skipping live filter validation');
console.warn('[preflight] no API_URL/APP_URL set, skipping live filter validation');
return;
}
@ -82,7 +82,7 @@ async function validateAgainstLiveApi(): Promise<void> {
};
featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name)));
} catch (err) {
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}) skipping validation`);
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}), skipping validation`);
return;
}
@ -134,7 +134,7 @@ async function main(): Promise<void> {
for (const sb of storyboards) emitScript(sb);
// Index for shell loops each entry has every field render.sh needs to
// Index for shell loops: each entry has every field render.sh needs to
// address per-storyboard outputs without re-parsing the TS source.
const index = {
storyboards: storyboards.map((sb) => ({

View file

@ -4,7 +4,7 @@ import { viewportFor } from './script.js';
import { storyboards } from './storyboard.js';
async function main() {
// probe is a debug utility pin it to the first storyboard's viewport.
// probe is a debug utility: pin it to the first storyboard's viewport.
const viewport = viewportFor(storyboards[0].video);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({

View file

@ -115,7 +115,7 @@ async function runCue(
`Trim a during step, lengthen the cue text, or move work into tail.`
);
}
// Time the during block as a whole individual steps may overrun their
// Time the during block as a whole: individual steps may overrun their
// budgets, but what matters at the cue boundary is total wall-clock.
const duringStart = Date.now();
for (const step of during) {
@ -393,7 +393,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
case 'dragSheet': {
const sheet = ctx.page.locator('section[class*="rounded-t-2xl"]').first();
const sheetBox = await sheet.boundingBox().catch(() => null);
if (!sheetBox) return; // desktop layout nothing to drag
if (!sheetBox) return; // desktop layout: nothing to drag
const handle = ctx.page
.locator('section[class*="rounded-t-2xl"] [class*="touch-none"]')
.first();
@ -427,7 +427,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
}
case 'openFilterGroup':
// Click is idempotent: if the group is already expanded, the click
// would collapse it which we don't want. Detect via aria-expanded
// would collapse it, which we don't want. Detect via aria-expanded
// (Radix Accordion sets it on the trigger) and skip the click when
// the group is already open.
await ctx.page.evaluate((selector) => {
@ -493,7 +493,7 @@ async function tryResolveTarget(
/**
* Load synth's measured cue durations. Falls back to a worst-case estimate
* if the manifest is missing that path is only used for ``--no-audio``
* if the manifest is missing: that path is only used for ``--no-audio``
* runs, where the visual flow needs to play even without speech to time
* against.
*/
@ -516,7 +516,7 @@ function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
});
}
console.log(
`[runner] no ${path} found — using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
`[runner] no ${path} found. Using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
);
return storyboard.cues.map((cue, cueIndex) => ({
cueIndex,

View file

@ -4,14 +4,14 @@ import type { DashboardRecorder } from './dashboard.js';
/**
* Public scripting API for the demo video.
*
* The storyboard is a `Storyboard` an ordered list of narration cues, each
* The storyboard is a `Storyboard`: an ordered list of narration cues, each
* carrying the activities that play alongside it. Audio is generated FIRST
* (one batched Qwen call so the voice stays consistent across cues); the
* runner then reads the measured per-cue durations and slots `during`
* activities inside each cue's audio window.
*
* Why cue-anchored: the audio drives pacing. Re-running synth produces a new
* set of measured durations and the storyboard self-aligns you don't have
* set of measured durations and the storyboard self-aligns: you don't have
* to retune activity numbers. Author intent stays declarative ("zoom + type
* happen during this cue, dwell 4s after, then next cue starts").
*/
@ -62,7 +62,7 @@ export interface AdScene {
items?: AdSceneItem[];
/** Optional single hero photo (URL) shown above the title. */
image?: string;
/** Optional [left, right] photos for split mode used by "two streets apart" style ads. */
/** Optional [left, right] photos for split mode, used by "two streets apart" style ads. */
images?: [string, string];
/** Optional caption shown under the image for attribution / context. */
imageCaption?: string;
@ -82,7 +82,7 @@ export type Target =
/**
* Resolved at runtime to the centre of a visible hexagon/postcode polygon,
* picked from the dashboard's most recent map response. Robust to any zoom
* level use this when the click MUST land on a polygon and a fixed pixel
* level. Use this when the click MUST land on a polygon and a fixed pixel
* coordinate would risk landing on a road or river at deep zoom.
*/
| { kind: 'hexagon' }
@ -195,7 +195,7 @@ export type Activity =
| { kind: 'dragSheet'; toHeightFrac: number; durationMs: number }
/**
* Click the header of a collapsible filter group (e.g. "Transport",
* "Schools") so the cards beneath it become visible. Idempotent
* "Schools") so the cards beneath it become visible. Idempotent:
* if the group is already open this is a no-op click.
*/
| { kind: 'openFilterGroup'; selector: string; durationMs: number }
@ -206,7 +206,7 @@ export type Activity =
* A narration cue + the activities that play alongside it.
*
* text : the SPOKEN narration line (TTS input). Never rendered on
* screen what's said must not also be shown.
* screen. What's said must not also be shown.
* caption : optional SHORT on-screen chip (6 words) complementing the
* narration. Distinct from `text` by design: visual hooks
* ("You can't hear a photo") and stats live here, the story
@ -240,7 +240,7 @@ export interface VideoConfig {
captureScale: number;
/**
* Optional CSS viewport override (in pixels). Lets a single storyboard
* record at a narrower CSS viewport than the aspect's default e.g.
* record at a narrower CSS viewport than the aspect's default, e.g.
* the recording-*-mobile cuts use 540x960 so Tailwind's `md:`
* breakpoint (768px) doesn't match and every component picks its
* mobile typography. Pair with `captureScale: 2` to keep text sharp.
@ -249,7 +249,7 @@ export interface VideoConfig {
viewport?: { width: number; height: number };
/**
* Visual style of the injected cursor. 'arrow' (default) renders the
* classic pointer right for desktop demos. 'touch' renders a soft
* classic pointer, right for desktop demos. 'touch' renders a soft
* fingertip dot, which reads as a phone gesture on 9:16 mobile cuts
* (an arrow cursor on a phone-shaped video instantly breaks the
* illusion that you're watching the mobile product).
@ -340,7 +340,7 @@ export interface TravelTimeFilter {
* `post` runs once after the last cue's tail finishes. The cue list is what
* gets handed to the synth step.
*
* `name` doubles as the on-disk slug outputs go to `output/<name>/` and
* `name` doubles as the on-disk slug: outputs go to `output/<name>/` and
* publish as `<name>.mp4` + `<name>.jpg`. Keep names URL/path-safe.
*/
export interface Storyboard {
@ -358,7 +358,7 @@ export interface Storyboard {
/**
* Frontend viewport in CSS pixels. Defaults to the aspect's native size
* (1920x1080 for 16x9, 1080x1920 for 9x16). A storyboard can opt into a
* narrower CSS viewport via `video.viewport` e.g. recording-*-mobile
* narrower CSS viewport via `video.viewport`, e.g. recording-*-mobile
* uses 540x960 so the frontend's Tailwind `md:` breakpoint doesn't match
* and every component picks mobile typography/spacing. Pair the override
* with `captureScale: 2` to keep text sharp at the smaller resolution.
@ -375,7 +375,7 @@ export function viewportFor(video: VideoConfig): { width: number; height: number
*
* Playwright's recordVideo captures the page at its CSS-pixel surface, so
* passing a size larger than the viewport just letterboxes the content
* into the top-left of an empty frame not a true high-DPR raster.
* into the top-left of an empty frame, not a true high-DPR raster.
* Final-resolution upscale (e.g. mobile 540x960 1080x1920) is done in
* render.sh's ffmpeg pass with `scale=...:flags=lanczos`, which gives a
* sharp upscale because Chromium rasterises internally at DPR=captureScale.
@ -387,7 +387,7 @@ export function recordedSizeFor(video: VideoConfig): { width: number; height: nu
/**
* The final mp4's resolution (after the lanczos upscale pass in render.sh).
* Storyboards drive their on-screen typography from CSS viewport sizes, but
* social platforms care about the file resolution so we expose a
* social platforms care about the file resolution, so we expose a
* separate getter for the published dimensions.
*/
export function publishedSizeFor(video: VideoConfig): { width: number; height: number } {

View file

@ -29,7 +29,7 @@ type FormFactor = 'desktop' | 'mobile';
* whenever the map is the story.
* 4. Filter names MUST match live /api/features exactly (e.g.
* "Serious crime (/yr, 7y)", "Distance to nearest amenity (Waitrose)
* (km)") wrong names silently no-op and the map never changes.
* (km)"). Wrong names silently no-op and the map never changes.
* preflight.ts validates every stubbed name against the live API.
*
* `name` doubles as the on-disk slug. The pipeline writes per-storyboard
@ -47,14 +47,14 @@ type FormFactor = 'desktop' | 'mobile';
// to modelled catchment counts ("Good+ primary school catchments"), which the
// local stack already serves; prod still serves the older "…within 2km" names
// until that deploy lands. Preflight fails loudly if these drift from
// whichever API render.sh is pointed at — flip them back if you render against
// whichever API render.sh is pointed at. Flip them back if you render against
// prod before the catchment model deploys there.
const SCHOOL_GOOD_PRIMARY = 'Good+ primary school catchments';
const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
// Cold-open lean-in on the AI card. Desktop only; kept moderate so the
// map remains visible on the right (zoomTo clamps the pan so the app
// always covers the full frame no backdrop voids).
// always covers the full frame: no backdrop voids).
const AI_ZOOM_SCALE_DESKTOP = 1.45;
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
@ -62,7 +62,7 @@ const TT_SLIDER_MAX = 120;
const TT_DRAG_FROM_MIN = 35;
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
// enough that street-level central London keeps plenty of matching
// postcodes — at 20 the brief emptied the centre and the postcode tap had
// postcodes. At 20 the brief emptied the centre and the postcode tap had
// nothing fresh to land on (the drawer then opened in its "filtered stats
// are empty" fallback).
const TT_DRAG_TO_MIN = 25;
@ -356,8 +356,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
{ kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 },
{
// Zoom AROUND the strongest visible match (hex()) so the
// destination area is guaranteed to contain matching postcodes
// a fixed pixel target can dive into a part of town the filters
// destination area is guaranteed to contain matching postcodes.
// A fixed pixel target can dive into a part of town the filters
// just emptied. Settled so the next cue's hex() click projects
// against the postcode response for the FINAL viewport.
kind: 'mapZoom',
@ -424,7 +424,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
// The drawer's close-button aria-label is localized
// (mobileDrawer.closeDrawer), so use the per-locale string.
// clickIfVisible keeps a label mismatch from crashing the
// take — worst case the drawer lingers behind the zoom-out.
// take. Worst case, the drawer lingers behind the zoom-out.
kind: 'clickIfVisible',
target: el(`button[aria-label="${copy.closeDrawerLabel}"]`),
durationMs: 650,
@ -442,7 +442,7 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
{ kind: 'zoomReset', durationMs: 800 },
{
// IfVisible: the export button only renders on ≥1024px-wide
// dashboards with a licensed user — a missing button must not
// dashboards with a licensed user. A missing button must not
// crash the take, just skip the ripple.
kind: 'clickIfVisible',
target: el(`button[title="${copy.exportButtonTitle}"]`),
@ -510,13 +510,13 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
outputFps: 50,
minDurationS: 10,
maxDurationS: 75,
// Street-level zoom with the sheet collapsed the map is the frame.
// Street-level zoom with the sheet collapsed: the map is the frame.
posterTimeS: 25,
};
}
return {
aspect: '16x9',
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport
// Native 1920x1080. NOTE: don't be tempted by a narrower CSS viewport:
// the dashboard header switches to tablet sidebar nav between 768 and
// 1023px and the Export button (cue 7) disappears.
captureScale: 1,
@ -528,7 +528,7 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig {
// Right-pane inspection: London map, filters applied, data pane
// FULLY populated (Street View + price history). 31s caught the pane
// mid-load on prod (empty white + spinner); 35s lands a few seconds into
// the "open" cue once the evidence has rendered the strongest preview.
// the "open" cue once the evidence has rendered: the strongest preview.
posterTimeS: 35,
};
}
@ -545,7 +545,7 @@ function createRecordingStoryboard(
// Both form factors open at zoom 12 so the cold-open frame lands on a
// densely-populated inner-London slice rather than the low-density outer
// edges. Desktop was 11.5, but the opening lean-in panned the visible map
// toward the sparse NW suburbs (Wembley/Ealing) a soft first frame for
// toward the sparse NW suburbs (Wembley/Ealing), a soft first frame for
// the hero. 12 keeps central London (rich default-density colouring) in
// shot from t=0.
const initialZoom = 12;
@ -586,10 +586,10 @@ function createRecordingStoryboard(
// £600k (not £315k like the old Manchester cut): central London is
// pricier, and at £315k the price+crime combo emptied the inner
// boroughs. At £600k ~51k postcodes pass in-frame, dropping to ~10k
// once the commute is dragged to 25 min a visible prune that still
// once the commute is dragged to 25 min, a visible prune that still
// leaves the zoom something to land on (verified via /api/filter-counts).
'Estimated current price': [0, 600000],
// Loose enough to keep the central-London map richly populated — a
// Loose enough to keep the central-London map richly populated. A
// cap of 20 emptied the city centre and left the zoom with nothing
// to land on.
'Serious crime (/yr, 7y)': [0, 40],
@ -646,7 +646,7 @@ const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
// the story, and a fingertip-style cursor sells the gestures as touch.
// ---------------------------------------------------------------------------
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds';
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds' | 'liverpool';
interface DemoAdCueConfig {
text: string;
@ -675,9 +675,11 @@ interface DemoAdStoryboardConfig {
travelTimeFilters?: TravelTimeFilter[];
posterTimeS?: number;
initialZoom?: number;
/** Override the city centre (e.g. to frame a specific cheaper-twin pair). */
center?: { lat: number; lon: number };
/** Plausible per-city "{{count}} properties match" total for the AI summary. */
matchCount?: number;
/** Activities to run before the cue loop starts (silent keep short). */
/** Activities to run before the cue loop starts (silent, keep short). */
prePrime?: Activity[];
/** Spoken line during the outro card. Must NOT repeat the card's text. */
outroLine: string;
@ -692,7 +694,7 @@ const AD_VIDEO: VideoConfig = {
webmBitrate: '4M',
outputFps: 50,
minDurationS: 8,
// Generous upper bound ads with a deep mapZoom drift a second or two
// Generous upper bound: ads with a deep mapZoom drift a second or two
// past their declared budgets while tiles load.
maxDurationS: 35,
posterTimeS: 5,
@ -734,6 +736,7 @@ const CITY_VIEWS: Record<CityKey, { lat: number; lon: number; zoom: number }> =
bristol: { lat: 51.3245, lon: -2.5879, zoom: 11.3 },
london: { lat: 51.4272, lon: -0.1276, zoom: 10.4 },
leeds: { lat: 53.7308, lon: -1.5491, zoom: 11.0 },
liverpool: { lat: 53.4084, lon: -2.9916, zoom: 11.2 },
};
// -- small helpers used by the per-ad cue lists -------------------------------
@ -782,7 +785,7 @@ const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({
});
/**
* Tap the centre of the highest-priority visible postcode polygon from the
* latest map response (robust to zoom drift a fixed pixel target at deep
* latest map response (robust to zoom drift: a fixed pixel target at deep
* zoom can land on a road or river and the drawer never opens).
*/
const tapHex = (durationMs = 1000): Activity => ({
@ -814,9 +817,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
ad.promptText ??
'Flat under £350k, good commute, good schools, lower crime, quieter streets';
const cityView = CITY_VIEWS[ad.city];
const initialMapView = {
...CITY_VIEWS[ad.city],
zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom,
...cityView,
lat: ad.center?.lat ?? cityView.lat,
lon: ad.center?.lon ?? cityView.lon,
zoom: ad.initialZoom ?? cityView.zoom,
};
return {
@ -877,7 +883,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
// 01 The hero feature: the whole brief in one sentence.
// 01. The hero feature: the whole brief in one sentence.
// Cold open ON the typing; map reveal as the payoff.
// -------------------------------------------------------------------
{
@ -922,7 +928,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 02 The commute slider. Cold open on a travel-time-coloured map
// 02. The commute slider. Cold open on a travel-time-coloured map
// (filter + colour applied in pre), then the 60→20 drag is the story.
// -------------------------------------------------------------------
{
@ -973,8 +979,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 03 Tap a postcode, read its file. The drawer (sold prices, Street
// View, schools, crime) is the wow — most viewers don't know this exists.
// 03. Tap a postcode, read its file. The drawer (sold prices, Street
// View, schools, crime) is the wow. Most viewers don't know this exists.
// -------------------------------------------------------------------
{
name: 'ad-03-postcode-files',
@ -1029,7 +1035,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 04 Noise. The strongest single line in the set; the product proves it.
// 04. Noise. The strongest single line in the set; the product proves it.
// -------------------------------------------------------------------
{
name: 'ad-04-quiet-streets',
@ -1066,7 +1072,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 05 Families / the school run. Leeds for non-London variety.
// 05. Families / the school run. Leeds for non-London variety.
// -------------------------------------------------------------------
{
name: 'ad-05-school-run',
@ -1106,7 +1112,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 06 Lifestyle amenities. The Waitrose line is the hook; tube + park
// 06. Lifestyle amenities. The Waitrose line is the hook; tube + park
// make it practical. Names match the live amenity-distance features.
// -------------------------------------------------------------------
{
@ -1145,7 +1151,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 07 Renters. Every tool is for buyers; rent is a live feature here.
// 07. Renters. Every tool is for buyers; rent is a live feature here.
// -------------------------------------------------------------------
{
name: 'ad-07-renters-map',
@ -1185,7 +1191,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
},
// -------------------------------------------------------------------
// 08 Value. The cost of overpaying for a name vs the one-off fee.
// 08. Value. The cost of overpaying for a name vs the one-off fee.
// -------------------------------------------------------------------
{
name: 'ad-08-value',
@ -1228,6 +1234,187 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
// ---------------------------------------------------------------------------
// Cheaper-twin spots: pair-centred value reveals generated from the
// analysis/ twin index. Filter names verified against live /api/features.
// Render with: VIDEO_STORYBOARD_SET=twins ./render.sh --prod
// ---------------------------------------------------------------------------
const TWIN_AD_CONFIGS: DemoAdStoryboardConfig[] = [
{
name: 'twin-beckenham-croydon',
matchCount: 1840,
city: 'london',
center: { lat: 51.38969, lon: -0.04244 },
initialZoom: 12.2,
promptText: 'Good schools, best value per square metre near Beckenham and Croydon',
filters: {
'Est. price per sqm': [0, 5200],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Beckenham's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Beckenham and Croydon sit side by side: same trains, same school catchment.',
caption: 'Same station. Same schools.',
during: [
typeAct('Good schools, best value per square metre near Beckenham and Croydon', 2600),
],
tail: [wait(150)],
},
{
text: 'Rank them by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a third less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-ha7-2-vs-ha3-0',
matchCount: 1620,
city: 'london',
center: { lat: 51.59199, lon: -0.3079 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Stanmore and Kenton',
filters: {
'Est. price per sqm': [0, 5900],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Stanmore's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Stanmore and Kenton sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Stanmore and Kenton', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a sixth less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-m40-5-vs-m9-4',
matchCount: 2480,
city: 'manchester',
center: { lat: 53.51293, lon: -2.19574 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Newton Heath and Harpurhey',
filters: {
'Est. price per sqm': [0, 1700],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Newton Heath's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Newton Heath and Harpurhey sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Newton Heath and Harpurhey', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs over a third less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-l16-7-vs-l14-6',
matchCount: 1740,
city: 'liverpool',
center: { lat: 53.40344, lon: -2.88529 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Childwall and Broadgreen',
filters: {
'Est. price per sqm': [0, 3000],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Childwall's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Childwall and Broadgreen sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Childwall and Broadgreen', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a third less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
{
name: 'twin-rm14-2-vs-rm12-5',
matchCount: 1980,
city: 'london',
center: { lat: 51.54892, lon: 0.22193 },
initialZoom: 12.0,
promptText: 'Good schools, best value per square metre near Upminster and Hornchurch',
filters: {
'Est. price per sqm': [0, 5300],
'Good+ secondary school catchments': [1, 11],
},
posterTimeS: 7,
outroLine: "Upminster's cheaper twin is on this map. Find yours, free.",
cues: [
{
text: 'Upminster and Hornchurch sit right next door, with the same schools and transport links.',
caption: 'Same area. Same schools.',
during: [typeAct('Good schools, best value per square metre near Upminster and Hornchurch', 2600)],
tail: [wait(150)],
},
{
text: 'Rank every postcode by what each pound of floor space actually buys.',
caption: 'Ranked by £ per m²',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'One postcode over, the same home quietly costs about a fifth less.',
caption: 'The cheaper twin',
during: [mapZoomIn(1400, 3)],
tail: [wait(400)],
},
],
},
];
const TWIN_AD_STORYBOARDS = TWIN_AD_CONFIGS.map(createDemoAdStoryboard);
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
export const storyboards: Storyboard[] = (() => {
@ -1238,6 +1425,8 @@ export const storyboards: Storyboard[] = (() => {
return DEMO_STORYBOARDS;
case 'all':
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
case 'twins':
return TWIN_AD_STORYBOARDS;
case 'ads':
default:
return AD_STORYBOARDS;