1255 lines
46 KiB
TypeScript
1255 lines
46 KiB
TypeScript
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 1–2 short sentences (~0.45s/word with the
|
||
* current voices). Homepage demo ≈45s, ads ≈15–20s. 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
|
||
// 15–20 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;
|
||
}
|