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//` and publishes `.mp4` / `.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 = { 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; /** Filters already applied at load (via URL) for cold-open-on-results ads. */ initialFilters?: Record; 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 = { 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; }