perfect-postcode/video/src/storyboard.ts
2026-06-22 22:04:20 +01:00

1255 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
el,
hex,
vfrac,
type Activity,
type Storyboard,
type TravelTimeFilter,
type VideoConfig,
} from './script.js';
type FormFactor = 'desktop' | 'mobile';
/**
* The list of demo videos to render, in order.
*
* Authoring rules (these are what earlier cuts kept getting wrong):
*
* 1. SAID ≠ SHOWN. The spoken `text` is never rendered on screen, and it
* must never describe what the viewer can already see ("now we zoom
* in…"). The voice carries intent and benefit; the product carries
* proof. The optional `caption` is a ≤6-word hook chip that says
* something DIFFERENT from the narration.
* 2. TIGHT TIMELINES. Cues are 12 short sentences (~0.45s/word with the
* current voices). Homepage demo ≈45s, ads ≈1520s. Tails are short
* breaths, not dwells.
* 3. THE PRODUCT IS THE HERO. Every cue has the dashboard doing real work
* (typing, submitting, dragging, zooming, tapping postcodes, scrolling
* real data). On 9:16 cuts the bottom sheet gets dragged out of the way
* whenever the map is the story.
* 4. Filter names MUST match live /api/features exactly (e.g.
* "Serious crime (avg/yr)", "Distance to nearest amenity (Waitrose)
* (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
* artefacts to `output/<name>/` and publishes `<name>.mp4` / `<name>.jpg`
* to the homepage. The default storyboard is named `recording` so the
* existing homepage `/video/recording.mp4` keeps working unchanged.
*
* Audio is generated first (one batched Qwen call per storyboard, using
* its own voice config), so each cue's actual duration is known before
* recording. The runner sizes each cue's wall-time to the measured audio
* length, padding short `during` blocks with a trailing wait.
*/
// School features as served by live /api/features. The data pipeline moved
// 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
// 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).
const AI_ZOOM_SCALE_DESKTOP = 1.45;
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
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
// nothing fresh to land on (the drawer then opened in its "filtered stats
// are empty" fallback).
const TT_DRAG_TO_MIN = 25;
// Where on the map the demo zoom-in lands. Desktop targets a fixed pixel
// in the 1920x1080 viewport; mobile targets the upper-third of the visible
// map area (the bottom of the 540x960 viewport is occupied by the
// MobileBottomSheet until it gets dragged away).
const MAP_FOCUS_DESKTOP = vfrac(1140 / 1920, 605 / 1080);
const MAP_FOCUS_MOBILE = vfrac(0.5, 0.34);
const HOMEPAGE_RIGHT_PANE_SELECTOR =
'[data-tutorial="right-pane"], .fixed.inset-0.z-50:has(button[aria-label="Close drawer"])';
const MOBILE_MAP_ZOOM_STEPS = 9;
const MOBILE_MAP_ZOOM_MS = 2200;
// 11 wheel-steps ≈ +3.1 zoom: deep enough that hexagons become postcode
// polygons, shallow enough that several matching blocks stay in frame and
// the deck.gl layer count doesn't thrash the main thread (14+ steps dove
// into sparse street tiles and stalled every page.evaluate for seconds).
const DESKTOP_MAP_ZOOM_STEPS = 11;
const DESKTOP_MAP_ZOOM_MS = 2800;
// Bottom-sheet height fractions for dragSheet. The MobileBottomSheet clamps
// to its own minimum (~132px at 960h ≈ 0.14), so aiming slightly below that
// pins it to the grab-handle sliver and hands the frame to the map.
const SHEET_DOWN = 0.12;
const SHEET_UP = 0.5;
type RecordingLocale = 'en' | 'de' | 'zh' | 'hi';
interface RecordingLocalization {
name: string;
appLanguage: string;
ttsLanguage: string;
voiceInstruct: string;
voiceReferenceText: string;
promptText: string;
travelTimeLabel: string;
exportButtonTitle: string;
/** Text of the ExportMenu modal's full-width confirm button (header.exportLabel). */
exportConfirmLabel: string;
/** Localized aria-label of the mobile drawer's close button (mobileDrawer.closeDrawer). */
closeDrawerLabel: string;
colourMapTitle: string;
brand: {
name: string;
tagline: string;
url: string;
};
cues: {
hook: string;
brief: string;
search: string;
commute: string;
streets: string;
open: string;
shortlist: string;
outro: string;
};
}
// The outro card strips any leading "https://" via dom.ts so the CTA renders
// as a bare domain. Keep this constant in URL form here since /api/* calls
// and other internal links still expect a scheme.
const BRAND_URL = 'https://perfect-postcode.co.uk';
const RECORDING_LOCALIZATIONS: Record<RecordingLocale, RecordingLocalization> = {
en: {
name: 'recording',
appLanguage: 'en',
ttsLanguage: 'English',
voiceInstruct:
'Calm and cheerful young British male narrator from the North of England with a ' +
'strong Manchester accent.',
voiceReferenceText:
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
promptText:
'First home under £600k, 35 min to central London, good schools, low crime, quiet street, fast broadband',
travelTimeLabel: 'Central London',
exportButtonTitle: 'Export to Excel',
exportConfirmLabel: 'Export',
closeDrawerLabel: 'Close drawer',
colourMapTitle: 'Colour map',
brand: {
name: 'Perfect Postcode',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
},
cues: {
hook: "A postcode's reputation is priced in. Its value isn't.",
brief:
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
search: 'One brief, and England narrows to the postcodes worth your money.',
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
streets: 'Down at street level, the strongest streets start to stand out.',
open:
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
shortlist:
'Keep the best-value few, export them, and scout where it actually counts.',
outro: 'Stop paying for the name. Find the value.',
},
},
de: {
name: 'recording-de',
appLanguage: 'de',
ttsLanguage: 'German',
voiceInstruct:
'Calm and cheerful German male narrator with clear standard German pronunciation ' +
'and a friendly, practical delivery.',
voiceReferenceText:
'Willkommen zur Demonstration. Diese Sprecherstimme hörst du im gesamten Video.',
promptText:
'Wohnung unter £600k, 35 Min. ins Zentrum von London, gute Schulen, niedrige Kriminalität, ruhige Straßen',
travelTimeLabel: 'Zentrum von London',
exportButtonTitle: 'Nach Excel exportieren',
exportConfirmLabel: 'Exportieren',
closeDrawerLabel: 'Drawer schließen',
colourMapTitle: 'Karte einfärben',
brand: {
name: 'Perfect Postcode',
tagline: 'Finde die unterschätzte Postleitzahl.',
url: BRAND_URL,
},
cues: {
hook: 'Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.',
brief:
'Also sag, was du suchst. Budget, Arbeitsweg, Schulen, sogar wie ruhig die Straße ist.',
search:
'Eine Vorgabe, und England schrumpft auf die Postleitzahlen, die dein Geld wert sind.',
commute: 'Verschärf den Arbeitsweg, und in Sekunden bleiben nur die besten übrig.',
streets: 'Auf Straßenebene stechen die besten Ecken von selbst hervor.',
open:
'Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.',
shortlist:
'Behalte die paar mit dem besten Preis-Leistungs-Verhältnis, exportier sie und schau sie dir vor Ort an.',
outro: 'Zahl nicht für den Namen. Finde den Wert.',
},
},
zh: {
name: 'recording-zh',
appLanguage: 'zh',
ttsLanguage: 'Chinese',
voiceInstruct:
'Calm and cheerful Mandarin Chinese male narrator with clear standard Mandarin ' +
'pronunciation and a friendly, practical delivery.',
voiceReferenceText: '欢迎观看演示。整段视频都会使用这位旁白的声音。',
promptText: '60万英镑以内的公寓35分钟到伦敦市中心学校好犯罪率低街道安静',
travelTimeLabel: '伦敦市中心',
exportButtonTitle: '导出为 Excel',
exportConfirmLabel: '导出',
closeDrawerLabel: '关闭侧栏',
colourMapTitle: '地图着色',
brand: {
name: 'Perfect Postcode',
tagline: '发现那个被低估的邮区。',
url: BRAND_URL,
},
cues: {
hook: '一个邮区的名气,早就算进了房价里。它真正的价值,却没有。',
brief: '所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。',
search: '一份需求,整个英格兰就缩小到那些真正值这个价的邮区。',
commute: '把通勤再收紧一点,几秒钟,留下的好选择又少了一批。',
streets: '放大到街道层面,好街区自己浮现出来。',
open: '点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。',
shortlist: '留下最划算的那几个,导出来,去真正值得的地方实地看房。',
outro: '别再为名气买单。找到真正的价值。',
},
},
hi: {
name: 'recording-hi',
appLanguage: 'hi',
ttsLanguage: 'English',
voiceInstruct:
'Calm and cheerful Indian male narrator speaking English with a strong Indian accent ' +
'and a friendly, practical delivery.',
voiceReferenceText:
"Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.",
promptText: 'Flat under £600k, 35 min to central London, good schools, low crime, quiet streets',
travelTimeLabel: 'Central London',
exportButtonTitle: 'Excel में export करें',
exportConfirmLabel: 'Export',
closeDrawerLabel: 'ड्रॉअर बंद करें',
colourMapTitle: 'मानचित्र रंगें',
brand: {
name: 'Perfect Postcode',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
},
cues: {
hook: "A postcode's reputation is priced in. Its value isn't.",
brief:
'So start with the brief. Budget, commute, schools, even how quiet the street is.',
search: 'One brief, and England narrows to the postcodes worth your money.',
commute: 'Tighten the commute, and the keepers narrow further in seconds.',
streets: 'Down at street level, the strongest streets start to stand out.',
open:
'Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.',
shortlist:
'Keep the best-value few, export them, and scout where it actually counts.',
outro: 'Stop paying for the name. Find the value.',
},
},
};
/**
* Homepage demo cues. ~45s total: hook → type brief → search → commute
* slider + colour layer → street-level zoom → open a postcode (real data
* pane) → export → outro. No on-screen captions: the homepage player runs
* with sound, the narration carries the story, and the product stays
* unobstructed.
*/
function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard['cues'] {
const copy = RECORDING_LOCALIZATIONS[locale];
const isMobile = formFactor === 'mobile';
const mapFocus = isMobile ? MAP_FOCUS_MOBILE : MAP_FOCUS_DESKTOP;
const mapZoomSteps = isMobile ? MOBILE_MAP_ZOOM_STEPS : DESKTOP_MAP_ZOOM_STEPS;
const mapZoomMs = isMobile ? MOBILE_MAP_ZOOM_MS : DESKTOP_MAP_ZOOM_MS;
const colourTravelTime = el(`${TT_CARD_SELECTOR} button[title="${copy.colourMapTitle}"]`);
const definingCharacteristicsSelector =
'[data-tutorial="right-pane"] button:has-text("Defining characteristics"), ' +
'.fixed.inset-0.z-50:has(button[aria-label="Close drawer"]) button:has-text("Defining characteristics")';
return [
{
// Hook over the untouched dashboard; desktop leans into the filter
// panel so the brief lands big in the next beat.
text: copy.cues.hook,
gapBeforeMs: 0,
during: isMobile
? [{ kind: 'wait', durationMs: 500 }]
: [
{
kind: 'zoomTo',
target: el('[data-tutorial="ai-filters"]'),
scale: AI_ZOOM_SCALE_DESKTOP,
durationMs: 900,
},
],
tail: [{ kind: 'wait', durationMs: 150 }],
},
{
text: copy.cues.brief,
gapBeforeMs: 200,
during: [
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: copy.promptText,
durationMs: 4200,
},
],
tail: [{ kind: 'wait', durationMs: 120 }],
},
{
text: copy.cues.search,
gapBeforeMs: 250,
during: [
{
kind: 'submitForm',
formSelector: '[data-tutorial="ai-filters"] form',
durationMs: 2000,
waitForMapSettled: true,
timeoutMs: 15000,
},
...(isMobile ? [] : [{ kind: 'zoomReset', durationMs: 800 } as Activity]),
],
tail: [{ kind: 'wait', durationMs: 250 }],
},
{
text: copy.cues.commute,
gapBeforeMs: 300,
during: [
{
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
durationMs: 1600,
},
{ kind: 'click', target: colourTravelTime, durationMs: 700 },
],
tail: [{ kind: 'wait', durationMs: 300 }],
},
{
text: copy.cues.streets,
gapBeforeMs: 300,
during: [
...(isMobile
? [{ kind: 'dragSheet', toHeightFrac: SHEET_DOWN, durationMs: 800 } as Activity]
: []),
{ 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
// just emptied. Settled so the next cue's hex() click projects
// against the postcode response for the FINAL viewport.
kind: 'mapZoom',
target: hex(),
steps: mapZoomSteps,
durationMs: mapZoomMs,
center: true,
waitForMapSettled: true,
timeoutMs: 10000,
},
],
tail: [{ kind: 'wait', durationMs: 200 }],
},
{
text: copy.cues.open,
gapBeforeMs: 250,
during: [
{
kind: 'click',
target: hex(),
durationMs: 1100,
waitForSelectionReady: true,
timeoutMs: 8000,
},
...(isMobile
? []
: [
{
kind: 'zoomTo',
target: el(HOMEPAGE_RIGHT_PANE_SELECTOR),
scale: 1.3,
durationMs: 850,
} as Activity,
]),
{
kind: 'scrollPane',
selector: HOMEPAGE_RIGHT_PANE_SELECTOR,
top: isMobile ? 430 : 380,
durationMs: 850,
},
],
tail: [
{
kind: 'clickIfVisible',
target: el(definingCharacteristicsSelector),
durationMs: 600,
timeoutMs: 700,
},
{
kind: 'scrollPane',
selector: HOMEPAGE_RIGHT_PANE_SELECTOR,
top: isMobile ? 760 : 900,
durationMs: 850,
},
{ kind: 'wait', durationMs: 350 },
],
},
{
text: copy.cues.shortlist,
gapBeforeMs: 300,
during: isMobile
? [
{
// 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.
kind: 'clickIfVisible',
target: el(`button[aria-label="${copy.closeDrawerLabel}"]`),
durationMs: 650,
timeoutMs: 1500,
},
{
kind: 'mapZoom',
target: mapFocus,
steps: 5,
durationMs: 1300,
direction: 'out',
},
]
: [
{ kind: 'zoomReset', durationMs: 800 },
{
// IfVisible: the export button only renders on ≥1024px-wide
// 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}"]`),
durationMs: 800,
timeoutMs: 1500,
},
{ kind: 'wait', durationMs: 400 },
{
// Complete the flow: the header button opens the ExportMenu
// modal; confirm it so the demo shows a finished export
// rather than an abandoned dialog.
kind: 'clickIfVisible',
target: el(`button[class*="w-full"]:has-text("${copy.exportConfirmLabel}")`),
durationMs: 700,
timeoutMs: 1500,
},
],
tail: [
{ kind: 'wait', durationMs: 500 },
// Insurance: if the modal lingers (slow export resolve), dismiss it
// before the outro card comes up.
{ kind: 'pressKey', key: 'Escape', durationMs: 150 },
],
},
{
text: copy.cues.outro,
gapBeforeMs: 350,
during: [
{
kind: 'showOutro',
brand: copy.brand.name,
tagline: copy.brand.tagline,
url: copy.brand.url,
// Burn the spoken closer for sound-off / accessibility.
subtitle: copy.cues.outro,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1700 }],
},
];
}
function buildPre(): Storyboard['pre'] {
return [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'wait', durationMs: 120 },
];
}
function buildVideoConfig(formFactor: FormFactor): VideoConfig {
if (formFactor === 'mobile') {
return {
aspect: '9x16',
// 540x960 CSS viewport (narrower than Tailwind's 768px `md:`
// breakpoint) so the frontend picks mobile typography/spacing.
// DPR 2 makes the browser render internally at 1080x1920 device
// pixels for sharp antialiasing; the screencast still writes the
// mp4 at 540x960. Result: a sharp phone-sized 9:16 cut that
// genuinely looks mobile-styled, not desktop in portrait.
viewport: { width: 540, height: 960 },
captureScale: 2,
cursorStyle: 'touch',
webmBitrate: '4M',
outputFps: 50,
minDurationS: 10,
maxDurationS: 75,
// 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 —
// the dashboard header switches to tablet sidebar nav between 768 and
// 1023px and the Export button (cue 7) disappears.
captureScale: 1,
cursorStyle: 'arrow',
webmBitrate: '8M',
outputFps: 50,
minDurationS: 10,
maxDurationS: 75,
// 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.
posterTimeS: 35,
};
}
function storyboardName(copy: RecordingLocalization, formFactor: FormFactor): string {
return formFactor === 'mobile' ? `${copy.name}-mobile` : copy.name;
}
function createRecordingStoryboard(
locale: RecordingLocale,
formFactor: FormFactor
): Storyboard {
const copy = RECORDING_LOCALIZATIONS[locale];
// 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
// the hero. 12 keeps central London (rich default-density colouring) in
// shot from t=0.
const initialZoom = 12;
// On mobile the MobileBottomSheet covers the bottom ~44% of the
// viewport, so the map's geographic centre sits roughly at the seam
// between the visible map and the sheet. Shift the centre lat ~0.04°
// south so central London (51.515) lands in the upper half of the
// visible map area instead of getting hidden under the sheet. The
// desktop layout already has the map dominate the viewport, so it
// keeps the original centre. -0.13 lon centres on Holborn/Bloomsbury,
// between the West End and the City, so the Bank-station travel filter
// and the deep zoom-in both land on richly matched inner-London blocks.
const mapLat = formFactor === 'mobile' ? 51.475 : 51.515;
const mapLon = -0.13;
return {
name: storyboardName(copy, formFactor),
locale: formFactor === 'mobile' ? `${locale}-mobile` : locale,
video: buildVideoConfig(formFactor),
voice: {
instruct: copy.voiceInstruct,
language: copy.ttsLanguage,
referenceText: copy.voiceReferenceText,
temperature: 0.6,
topP: 0.9,
seed: 42,
},
content: {
promptText: copy.promptText,
appLanguage: copy.appLanguage,
aiZoomScale: AI_ZOOM_SCALE_DESKTOP,
initialMapView: { lat: mapLat, lon: mapLon, zoom: initialZoom },
// Filters returned by the AI stub. Keys MUST match real feature names
// from /api/features (preflight validates against the live server).
stubbedFilters: {
'Property type': ['Flats/Maisonettes', 'Semi-Detached'],
// £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
// 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
// cap of 20 emptied the city centre and left the zoom with nothing
// to land on.
'Serious crime (avg/yr)': [0, 40],
[SCHOOL_GOOD_PRIMARY]: [1, 10],
'Noise (dB)': [0, 65],
// ≥30 Mbps, not ≥100: FTTP coverage gaps make a 100 floor empty
// whole suburbs, and the narration only claims "fast broadband".
'Max available download speed (Mbps)': [30, 1000],
},
// Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response. Bank tube station is
// the central-London transit anchor served by the local stack (the
// older city-level "manchester" slug only exists on prod's fuller data).
stubbedTravelTimeFilters: [
{
mode: 'transit',
slug: 'bank-tube-station',
label: copy.travelTimeLabel,
max: TT_DRAG_FROM_MIN,
},
],
travelTimeCardSelector: TT_CARD_SELECTOR,
travelTimeSliderMax: TT_SLIDER_MAX,
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: copy.brand,
// A tight 7-filter inner-London search; a plausible total (not the
// shared stub default) so the on-screen count reads as real.
matchCount: 1843,
},
pre: buildPre(),
cues: createCues(locale, formFactor),
};
}
const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'de', 'zh', 'hi'];
const RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile'];
const ENGLISH_HOMEPAGE_STORYBOARDS: Storyboard[] = RECORDING_FORM_FACTORS.map((formFactor) =>
createRecordingStoryboard('en', formFactor)
);
const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) =>
RECORDING_FORM_FACTORS.map((formFactor) => createRecordingStoryboard(locale, formFactor))
);
// ---------------------------------------------------------------------------
// Social ads.
//
// Every ad is one hook + one real product interaction + one closer, in
// 1520 seconds of 9:16. Narration starts on the first frame and never
// reads what's on screen; the only on-screen text is a short hook chip on
// the opening cue. The bottom sheet is dragged away whenever the map is
// the story, and a fingertip-style cursor sells the gestures as touch.
// ---------------------------------------------------------------------------
type CityKey = 'manchester' | 'birmingham' | 'bristol' | 'london' | 'leeds';
interface DemoAdCueConfig {
text: string;
caption?: string;
gapBeforeMs?: number;
/** Activities that fit inside the cue's audio window. */
during?: Activity[];
/** Activities that run after the cue's audio ends, before the next cue. */
tail?: Activity[];
}
interface DemoAdStoryboardConfig {
name: string;
city: CityKey;
/**
* The text typed into the AI box. The AI stub returns `filters` /
* `travelTimeFilters` regardless of the typed text, so the prompt value
* here is purely for visual veracity (it must match what the filters
* imply, or sharp-eyed viewers cry fake).
*/
promptText?: string;
/** Filters returned by the AI stub after a type+submit. */
filters?: Record<string, [number, number] | string[]>;
/** Filters already applied at load (via URL) for cold-open-on-results ads. */
initialFilters?: Record<string, [number, number] | string[]>;
travelTimeFilters?: TravelTimeFilter[];
posterTimeS?: number;
initialZoom?: 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). */
prePrime?: Activity[];
/** Spoken line during the outro card. Must NOT repeat the card's text. */
outroLine: string;
cues: DemoAdCueConfig[];
}
const AD_VIDEO: VideoConfig = {
aspect: '9x16',
viewport: { width: 540, height: 960 },
captureScale: 2,
cursorStyle: 'touch',
webmBitrate: '4M',
outputFps: 50,
minDurationS: 8,
// Generous upper bound — ads with a deep mapZoom drift a second or two
// past their declared budgets while tiles load.
maxDurationS: 35,
posterTimeS: 5,
};
const AD_BRAND = {
name: 'Perfect Postcode',
tagline: 'Find the hidden-gem postcode.',
url: BRAND_URL,
};
/**
* Ad voice persona. The SAME config is used across every ad so the voice
* timbre stays consistent across the set (render.sh additionally reuses the
* first minted reference WAV for all of them).
*/
const AD_VOICE = {
instruct:
'British male creator-style narrator. Warm, confident, conversational, with a ' +
'slightly quick pace, like telling a friend about a great find. No salesy hype, ' +
'no exaggeration. Short sentences, natural delivery.',
language: 'English',
referenceText:
'This is a short social video for people choosing where to live in England.',
temperature: 0.58,
topP: 0.9,
seed: 87,
};
/**
* Initial map centres for each ad. Centres are shifted south of the true
* city centre so the interesting area sits in the upper (visible) half of
* the 9:16 frame while the bottom sheet is still up; once the sheet is
* dragged away the framing reads as comfortably centred.
*/
const CITY_VIEWS: Record<CityKey, { lat: number; lon: number; zoom: number }> = {
manchester: { lat: 53.3495, lon: -2.2451, zoom: 11.2 },
birmingham: { lat: 52.3562, lon: -1.8904, zoom: 11.0 },
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 },
};
// -- small helpers used by the per-ad cue lists -------------------------------
const AI_TEXTAREA = '[data-tutorial="ai-filters"] textarea';
const AI_FORM = '[data-tutorial="ai-filters"] form';
const typeAct = (text: string, durationMs = 2600): Activity => ({
kind: 'type',
selector: AI_TEXTAREA,
text,
durationMs,
});
const submitSettled = (durationMs = 1400, timeoutMs = 10000): Activity => ({
kind: 'submitForm',
formSelector: AI_FORM,
durationMs,
waitForMapSettled: true,
timeoutMs,
});
const ttDragAct = (toMin: number, durationMs = 1700): Activity => ({
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: toMin / TT_SLIDER_MAX,
durationMs,
});
const wait = (durationMs: number): Activity => ({ kind: 'wait', durationMs });
const sheetDown = (durationMs = 800): Activity => ({
kind: 'dragSheet',
toHeightFrac: SHEET_DOWN,
durationMs,
});
const sheetUp = (durationMs = 700): Activity => ({
kind: 'dragSheet',
toHeightFrac: SHEET_UP,
durationMs,
});
const touchShow = (): Activity => ({ kind: 'cursorScale', scale: 1, durationMs: 150 });
const touchHide = (): Activity => ({ kind: 'cursorScale', scale: 0, durationMs: 150 });
const mapZoomIn = (durationMs = 1500, steps = 5): Activity => ({
kind: 'mapZoom',
target: vfrac(0.5, 0.34),
steps,
durationMs,
});
/**
* 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
* zoom can land on a road or river and the drawer never opens).
*/
const tapHex = (durationMs = 1000): Activity => ({
kind: 'click',
target: hex(),
durationMs,
waitForSelectionReady: true,
timeoutMs: 8000,
});
// Right-pane / drawer used after a postcode tap.
const RIGHT_PANE_SELECTOR = '[data-tutorial="right-pane"]';
const scrollDrawer = (top: number, durationMs = 800): Activity => ({
kind: 'scrollPane',
selector: RIGHT_PANE_SELECTOR,
top,
durationMs,
});
/** Default silent pre: vignette + hidden cursor; narration starts at t≈0. */
const AD_PRE: Activity[] = [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
{ kind: 'wait', durationMs: 80 },
];
function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
const promptText =
ad.promptText ??
'Flat under £350k, good commute, good schools, lower crime, quieter streets';
const initialMapView = {
...CITY_VIEWS[ad.city],
zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom,
};
return {
name: ad.name,
locale: 'en',
video: { ...AD_VIDEO, posterTimeS: ad.posterTimeS ?? AD_VIDEO.posterTimeS },
voice: AD_VOICE,
content: {
promptText,
appLanguage: 'en',
aiZoomScale: 1,
initialMapView,
initialFilters: ad.initialFilters,
stubbedFilters: ad.filters ?? {},
stubbedTravelTimeFilters: ad.travelTimeFilters ?? [],
travelTimeCardSelector: TT_CARD_SELECTOR,
travelTimeSliderMax: TT_SLIDER_MAX,
travelTimeDragFromMin: TT_DRAG_FROM_MIN,
travelTimeDragToMin: TT_DRAG_TO_MIN,
brand: AD_BRAND,
matchCount: ad.matchCount,
},
pre: ad.prePrime ?? AD_PRE,
cues: [
...ad.cues.map((cue, index) => ({
text: cue.text,
caption: cue.caption,
gapBeforeMs: cue.gapBeforeMs ?? (index === 0 ? 0 : 250),
during: cue.during,
tail: cue.tail ?? [wait(300)],
})),
{
text: ad.outroLine,
gapBeforeMs: 250,
during: [
{
kind: 'showOutro',
brand: AD_BRAND.name,
tagline: AD_BRAND.tagline,
url: AD_BRAND.url,
// Burn the spoken closer onto the card so muted social viewers
// (the ads' real channel) still read the CTA.
subtitle: ad.outroLine,
durationMs: 0,
},
],
// Hold the brand card long enough to read the URL + closer with sound off.
tail: [wait(2200)],
},
],
};
}
// ---------------------------------------------------------------------------
// The ad set. 8 concepts, each: hook chip + one product interaction.
// All filter names verified against live /api/features.
// ---------------------------------------------------------------------------
const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// -------------------------------------------------------------------
// 01 — The hero feature: the whole brief in one sentence.
// Cold open ON the typing; map reveal as the payoff.
// -------------------------------------------------------------------
{
name: 'ad-01-say-it',
matchCount: 2410,
city: 'london',
promptText: 'Flat under £600k, 35 min to central London, low crime, quiet street',
filters: {
'Property type': ['Flats/Maisonettes'],
'Estimated current price': [0, 600000],
'Serious crime (avg/yr)': [0, 35],
'Noise (dB)': [0, 60],
},
travelTimeFilters: [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 35 },
],
initialZoom: 10.2,
posterTimeS: 8,
outroLine: 'The underpriced twin is on this map. Find it.',
cues: [
{
text: 'My whole house brief is one plain sentence.',
caption: "Reputation is priced in. Value isn't.",
during: [
typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600),
],
tail: [wait(150)],
},
{
text: 'Every postcode in England that fits, sorted by value.',
caption: 'All of England, by value',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Same schools, same commute, the price quietly drops nearby.',
caption: 'The cheaper twin nearby',
during: [mapZoomIn(1400, 4)],
tail: [wait(400)],
},
],
},
// -------------------------------------------------------------------
// 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.
// -------------------------------------------------------------------
{
name: 'ad-02-twenty-minute-map',
matchCount: 38600,
city: 'london',
promptText: 'Within an hour of central London',
filters: {},
travelTimeFilters: [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 60 },
],
initialZoom: 10.2,
posterTimeS: 9,
outroLine: 'The commute is priced in. The bargain is not.',
prePrime: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
// Paint the map by journey time, then hand the frame to the map.
{
kind: 'click',
target: el(`${TT_CARD_SELECTOR} button[title="Colour map"]`),
durationMs: 600,
},
{ kind: 'wait', durationMs: 500 },
sheetDown(700),
{ kind: 'wait', durationMs: 200 },
],
cues: [
{
text: 'Every colour on this map is a commute to central London.',
caption: 'Same commute, cheaper postcode',
during: [wait(400)],
tail: [wait(150)],
},
{
text: "Here's what twenty minutes actually leaves you.",
caption: 'What 20 minutes buys',
during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)],
tail: [touchHide(), sheetDown(800), wait(300)],
},
{
text: "Same twenty minutes wherever it's lit, but not the same price.",
caption: 'Same ride, lower price',
during: [mapZoomIn(1300, 3)],
tail: [wait(400)],
},
],
},
// -------------------------------------------------------------------
// 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',
matchCount: 1290,
city: 'london',
initialFilters: {
'Estimated current price': [0, 700000],
[SCHOOL_OUTSTANDING_PRIMARY]: [1, 10],
},
initialZoom: 10.6,
posterTimeS: 11,
outroLine: 'Every postcode, proven on the numbers, not its reputation.',
prePrime: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'cursorScale', scale: 0, durationMs: 0 },
sheetDown(700),
{ kind: 'wait', durationMs: 200 },
],
cues: [
{
text: 'One name costs more. The block next door scores the same.',
caption: 'See the proof, not the postcode',
during: [
{
// Zoom around the strongest visible match so the destination
// area contains matching postcodes; settled so the tapHex in
// the next cue projects against the final viewport's response.
kind: 'mapZoom',
target: hex(),
steps: 8,
durationMs: 2600,
center: true,
waitForMapSettled: true,
timeoutMs: 9000,
},
],
tail: [wait(150)],
},
{
text: 'Sold prices, schools, crime, even Street View, for this exact postcode.',
caption: 'The whole postcode file',
during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)],
tail: [wait(200)],
},
{
text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.',
caption: 'Same proof, less money',
during: [scrollDrawer(820, 900), touchHide()],
tail: [wait(350)],
},
],
},
// -------------------------------------------------------------------
// 04 — Noise. The strongest single line in the set; the product proves it.
// -------------------------------------------------------------------
{
name: 'ad-04-quiet-streets',
matchCount: 6420,
city: 'london',
promptText: 'Quiet London street under 55 decibels, low crime',
filters: {
'Noise (dB)': [0, 55],
'Serious crime (avg/yr)': [0, 35],
},
initialZoom: 10.6,
posterTimeS: 7,
outroLine: 'The quiet street nobody bid up.',
cues: [
{
text: 'Listing photos are silent. Main roads are not.',
caption: "The quiet that's overlooked",
during: [typeAct('Quiet London street under 55 decibels, low crime', 2400)],
tail: [wait(150)],
},
{
text: 'This is London under fifty-five decibels.',
caption: 'Under 55 decibels',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Nearby, just as quiet, and overlooked.',
caption: 'Quiet, and overlooked',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
],
},
// -------------------------------------------------------------------
// 05 — Families / the school run. Leeds for non-London variety.
// -------------------------------------------------------------------
{
name: 'ad-05-school-run',
matchCount: 3380,
city: 'leeds',
promptText: 'Family home in Leeds under £350k, good primary schools, low crime',
filters: {
'Estimated current price': [0, 350000],
[SCHOOL_GOOD_PRIMARY]: [2, 10],
'Serious crime (avg/yr)': [0, 30],
},
initialZoom: 11.0,
posterTimeS: 13,
outroLine: 'The schools are real. The premium is optional.',
cues: [
{
text: 'Good primary, low crime, under three hundred and fifty. The actual family brief.',
caption: 'Catchment, minus the premium',
during: [
typeAct('Family home in Leeds under £350k, good primary schools, low crime', 2800),
],
tail: [wait(150)],
},
{
text: 'Everything still on the map passes all three filters.',
caption: 'Passes all three',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Same primary, same low crime, without the name everyone else is bidding up.',
caption: 'Same catchment, less money',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
],
},
// -------------------------------------------------------------------
// 06 — Lifestyle amenities. The Waitrose line is the hook; tube + park
// make it practical. Names match the live amenity-distance features.
// -------------------------------------------------------------------
{
name: 'ad-06-waitrose-test',
matchCount: 880,
city: 'london',
promptText: 'Walking distance to a Waitrose, a tube station and a park',
filters: {
'Distance to nearest amenity (Waitrose) (km)': [0, 1],
'Distance to nearest amenity (Tube station) (km)': [0, 0.8],
'Distance to nearest amenity (Park) (km)': [0, 0.5],
},
initialZoom: 10.4,
posterTimeS: 7,
outroLine: 'Same Waitrose. Same tube. Cheaper postcode.',
cues: [
{
text: 'A Waitrose, a tube stop, a park nearby. Search by what you want.',
caption: 'The Waitrose test',
during: [typeAct('Walking distance to a Waitrose, a tube station and a park', 2800)],
tail: [wait(150)],
},
{
text: 'London, narrowed to the postcodes that fit the way you live.',
caption: 'Fits how you live',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Same amenities, lower price nearby.',
caption: 'Same life, lower price',
during: [mapZoomIn(1400, 4)],
tail: [wait(400)],
},
],
},
// -------------------------------------------------------------------
// 07 — Renters. Every tool is for buyers; rent is a live feature here.
// -------------------------------------------------------------------
{
name: 'ad-07-renters-map',
matchCount: 2160,
city: 'london',
promptText: 'Rent under £1,600, 30 min to central London, quiet street',
filters: {
'Estimated monthly rent': [0, 1600],
'Noise (dB)': [0, 58],
},
travelTimeFilters: [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 30 },
],
initialZoom: 10.3,
posterTimeS: 13,
outroLine: 'The name costs more. The street does not.',
cues: [
{
text: 'Rent under sixteen hundred, half an hour to central, on a quiet street.',
caption: 'Stop renting the reputation',
during: [typeAct('Rent under £1,600, 30 min to central London, quiet street', 2800)],
tail: [wait(150)],
},
{
text: 'Letting sites rank flats. This ranks the streets around them.',
caption: 'Areas, not just flats',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Same commute, same quiet, lower rent nearby.',
caption: 'Same street, lower rent',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
],
},
// -------------------------------------------------------------------
// 08 — Value. The cost of overpaying for a name vs the one-off fee.
// -------------------------------------------------------------------
{
name: 'ad-08-value',
matchCount: 1540,
city: 'london',
promptText: 'Under £500k, 35 min to central London, low crime, good schools',
filters: {
'Estimated current price': [0, 500000],
'Serious crime (avg/yr)': [0, 35],
[SCHOOL_GOOD_PRIMARY]: [1, 10],
},
travelTimeFilters: [
{ mode: 'transit', slug: 'london', label: 'Central London', max: 35 },
],
initialZoom: 10.4,
posterTimeS: 7,
outroLine: 'Buy value, not reputation.',
cues: [
{
text: 'Two streets, same schools, same commute, priced tens of thousands apart.',
caption: 'Two streets. One is overpriced.',
during: [typeAct('Under £500k, 35 min to central London, low crime, good schools', 2600)],
tail: [wait(150)],
},
{
text: "You pay for the postcode's name; the value sits a postcode away.",
caption: 'You pay for the name',
during: [submitSettled(1400)],
tail: [sheetDown(800), wait(250)],
},
{
text: 'Pay once, find the bargain, stop overpaying for the name.',
caption: 'Find the value instead',
during: [mapZoomIn(1600, 5)],
tail: [wait(400)],
},
],
},
];
const AD_STORYBOARDS = AD_CONFIGS.map(createDemoAdStoryboard);
const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'homepage-en';
export const storyboards: Storyboard[] = (() => {
switch (STORYBOARD_SET) {
case 'homepage-en':
return ENGLISH_HOMEPAGE_STORYBOARDS;
case 'demo':
return DEMO_STORYBOARDS;
case 'all':
return [...AD_STORYBOARDS, ...DEMO_STORYBOARDS];
case 'ads':
default:
return AD_STORYBOARDS;
}
})();
export function getStoryboard(name: string): Storyboard {
const sb = storyboards.find((s) => s.name === name);
if (!sb) {
throw new Error(
`Unknown storyboard "${name}". Known: ${storyboards.map((s) => s.name).join(', ')}`
);
}
return sb;
}