diff --git a/video/src/browser.ts b/video/src/browser.ts index fa838e5..4049743 100644 --- a/video/src/browser.ts +++ b/video/src/browser.ts @@ -148,7 +148,12 @@ async function suppressDevServerNoise(context: BrowserContext) { iframe[id*="webpack"], [id*="webpack-dev-server-client"], [class*="error-overlay"], - [class*="webpack-error"] { + [class*="webpack-error"], + /* Recording-only: hide dashboard chrome that reads as noise/loading + on camera (the "Finding the Perfect Postcode" AI-status CTA). The + data attribute is inert in production — only this injected rule + targets it, and only during a recording. */ + [data-video-hide] { display: none !important; visibility: hidden !important; opacity: 0 !important; diff --git a/video/src/dom.ts b/video/src/dom.ts index 5a9eae8..403cbbd 100644 --- a/video/src/dom.ts +++ b/video/src/dom.ts @@ -146,12 +146,16 @@ export async function installCursor( At the 540-wide CSS viewport this renders ~30px type → 60px in the published 1080-wide mp4: a proper social-video hook size. */ body.__demo-aspect-vertical #__demo-caption { - top: 8%; + top: 14%; max-width: 88vw; font-size: 30px; font-weight: 850; padding: 12px 18px; border-radius: 12px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } #__demo-caption.visible { opacity: 1; @@ -209,6 +213,13 @@ export async function installCursor( margin-left: auto; margin-right: auto; } + #__demo-outro-subtitle { + font: 500 22px/1.34 "Inter", system-ui, sans-serif; + color: #cbd5e1; + max-width: 30ch; + margin: 2px auto 4px; + text-align: center; + } #__demo-outro-url { display: inline-flex; align-items: center; @@ -228,6 +239,7 @@ export async function installCursor( comfortably inside the platform-safe centre column. */ body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; } body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; } + body.__demo-aspect-vertical #__demo-outro-subtitle { font-size: 20px; max-width: 24ch; } body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; } .__ad-scene { @@ -816,10 +828,11 @@ export async function showOutro( page: Page, brand: string, tagline: string, - url: string + url: string, + subtitle?: string ): Promise { await page.evaluate( - ({ brand, tagline, url }) => { + ({ brand, tagline, url, subtitle }) => { document.getElementById('__demo-caption')?.classList.remove('visible'); const el = document.createElement('div'); el.id = '__demo-outro'; @@ -835,14 +848,22 @@ export async function showOutro( urlEl.id = '__demo-outro-url'; // Drop the protocol so the CTA reads as a bare domain. urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, ''); - card.append(brandEl, taglineEl, urlEl); + // Optional spoken-outro line, burned in for muted social viewers. + if (subtitle && subtitle.length > 0) { + const subtitleEl = document.createElement('div'); + subtitleEl.id = '__demo-outro-subtitle'; + subtitleEl.textContent = subtitle; + card.append(brandEl, taglineEl, subtitleEl, urlEl); + } else { + card.append(brandEl, taglineEl, urlEl); + } el.appendChild(card); document.body.appendChild(el); requestAnimationFrame(() => { requestAnimationFrame(() => el.classList.add('visible')); }); }, - { brand, tagline, url } + { brand, tagline, url, subtitle } ); } diff --git a/video/src/narration.ts b/video/src/narration.ts index c53ad38..84a9d2b 100644 --- a/video/src/narration.ts +++ b/video/src/narration.ts @@ -35,3 +35,38 @@ class NarrationLog { } export const narrationLog = new NarrationLog(); + +function formatVttTimestamp(ms: number): string { + const clamped = Math.max(0, Math.round(ms)); + const totalSeconds = Math.floor(clamped / 1000); + const millis = clamped % 1000; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const pad = (n: number, width: number): string => + n.toString().padStart(width, '0'); + return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(millis, 3)}`; +} + +/** + * Render narration cues as a WebVTT document. + * + * Pure helper: takes cues (already ordered by the caller) and produces a + * caption sidecar string. Cue text is trimmed and any internal blank lines + * are collapsed so a single cue stays a single VTT block. + */ +export function cuesToVtt(cues: NarrationCue[]): string { + let out = 'WEBVTT\n\n'; + for (const cue of cues) { + const start = formatVttTimestamp(cue.videoTimeMs); + const end = formatVttTimestamp(cue.videoTimeMs + cue.durationMs); + const text = cue.text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n') + .trim(); + out += `${start} --> ${end}\n${text}\n\n`; + } + return out; +} diff --git a/video/src/record.ts b/video/src/record.ts index bc1e2d7..98d91ba 100644 --- a/video/src/record.ts +++ b/video/src/record.ts @@ -1,8 +1,8 @@ -import { existsSync, mkdirSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js'; import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js'; -import { narrationLog } from './narration.js'; +import { cuesToVtt, narrationLog } from './narration.js'; import { installDemoRoutes } from './routes.js'; import type { Storyboard } from './script.js'; import { storyboards } from './storyboard.js'; @@ -105,6 +105,11 @@ async function recordOne(storyboard: Storyboard): Promise { console.log( `[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}` ); + const vttPath = join(dir, 'narration.vtt'); + writeFileSync(vttPath, cuesToVtt(cues)); + console.log( + `[${storyboard.name}] wrote ${cues.length} WebVTT captions → ${vttPath}` + ); } async function main(): Promise { diff --git a/video/src/routes.ts b/video/src/routes.ts index 167496d..ad7ccd5 100644 --- a/video/src/routes.ts +++ b/video/src/routes.ts @@ -38,7 +38,7 @@ async function stubAiFilters(page: Page, storyboard: Storyboard) { filters: storyboard.content.stubbedFilters, travel_time_filters: storyboard.content.stubbedTravelTimeFilters, notes: '', - match_count: 1247, + match_count: storyboard.content.matchCount ?? 1247, }), }); }); diff --git a/video/src/runner.ts b/video/src/runner.ts index 9707586..cd73221 100644 --- a/video/src/runner.ts +++ b/video/src/runner.ts @@ -379,7 +379,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { return; } case 'showOutro': - await showOutro(ctx.page, step.brand, step.tagline, step.url); + await showOutro(ctx.page, step.brand, step.tagline, step.url, step.subtitle); return; case 'showAdScene': await showAdScene(ctx.page, step.scene); diff --git a/video/src/script.ts b/video/src/script.ts index 3a35cfd..93fe710 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -172,7 +172,7 @@ export type Activity = timeoutMs?: number; } /** Reveal the closing brand card. */ - | { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number } + | { kind: 'showOutro'; brand: string; tagline: string; url: string; subtitle?: string; durationMs: number } /** Reveal a full-screen ad-style overlay over the live map. */ | { kind: 'showAdScene'; scene: AdScene; durationMs: number } /** Fade the ad overlay away. */ @@ -318,6 +318,13 @@ export interface ContentConfig { travelTimeDragFromMin: number; travelTimeDragToMin: number; brand: BrandConfig; + /** + * Stubbed "{{count}} properties match" total shown in the AI summary. + * Set PER storyboard so different searches/cities don't all display the + * same number (an identical count across cuts reads as fake). Falls back + * to a default in routes.ts when omitted. + */ + matchCount?: number; } export interface TravelTimeFilter { diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 384eff8..0ce057b 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -148,21 +148,21 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: 'Colour map', brand: { name: 'Perfect Postcode', - tagline: 'Find the area before the house.', + tagline: 'Find the hidden-gem postcode.', url: BRAND_URL, }, cues: { - hook: "You can't view your way to the right area.", + hook: "A postcode's reputation is priced in. Its value isn't.", brief: - 'So start with the brief. Price, commute, schools, even how quiet the street is.', - search: 'One search, and England shrinks to the postcodes that fit.', - commute: 'Push the commute down, and the map answers in seconds.', + '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: - 'Shortlist the winners, export them, and only spend Saturdays where it counts.', - outro: 'Stop guessing postcodes. Start choosing them.', + 'Keep the best-value few, export them, and scout where it actually counts.', + outro: 'Stop paying for the name. Find the value.', }, }, de: { @@ -183,21 +183,22 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: 'Karte einfärben', brand: { name: 'Perfect Postcode', - tagline: 'Erst die Gegend, dann das Haus.', + tagline: 'Finde die unterschätzte Postleitzahl.', url: BRAND_URL, }, cues: { - hook: 'Das richtige Viertel findest du nicht durch Besichtigungen.', + hook: 'Der Ruf einer Gegend steckt im Preis. Ihr wahrer Wert nicht.', brief: - 'Fang mit dem Briefing an. Preis, Pendelzeit, Schulen, sogar wie ruhig die Straße ist.', - search: 'Eine Suche, und England schrumpft auf die Postleitzahlen, die passen.', - commute: 'Verkürze den Arbeitsweg, und die Karte antwortet in Sekunden.', + '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 ihre Daten. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.', + 'Öffne eine, und sie zeigt die Belege. Verkaufspreise, Schulen, Kriminalität, Lärm, Internet.', shortlist: - 'Speichere die Favoriten, exportiere sie, und verbringe deine Samstage nur dort, wo es zählt.', - outro: 'Hör auf, Postleitzahlen zu raten. Fang an, sie zu wählen.', + '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: { @@ -216,18 +217,18 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: '地图着色', brand: { name: 'Perfect Postcode', - tagline: '先选对街区,再选房子。', + tagline: '发现那个被低估的邮区。', url: BRAND_URL, }, cues: { - hook: '光靠看房,看不出哪个街区适合你。', - brief: '先列条件。预算、通勤、学校,甚至街道安不安静。', - search: '搜索一次,全英格兰只剩下符合条件的邮编。', - commute: '把通勤时间调短,地图几秒内就给出答案。', + hook: '一个邮区的名气,早就算进了房价里。它真正的价值,却没有。', + brief: '所以,把你的需求告诉它。预算、通勤、学校,甚至这条街够不够安静。', + search: '一份需求,整个英格兰就缩小到那些真正值这个价的邮区。', + commute: '把通勤再收紧一点,几秒钟,留下的好选择又少了一批。', streets: '放大到街道层面,好街区自己浮现出来。', - open: '点开一个,它会摆出证据。成交价、学校、犯罪率、噪音、宽带。', - shortlist: '把心仪的区域导出,周末只去值得去的地方。', - outro: '别再猜邮编,开始挑邮编。', + open: '点开一个,它把依据都摆给你看。成交价、学校、治安、噪音、宽带。', + shortlist: '留下最划算的那几个,导出来,去真正值得的地方实地看房。', + outro: '别再为名气买单。找到真正的价值。', }, }, hi: { @@ -247,21 +248,21 @@ const RECORDING_LOCALIZATIONS: Record = colourMapTitle: 'मानचित्र रंगें', brand: { name: 'Perfect Postcode', - tagline: 'Find the area before the house.', + tagline: 'Find the hidden-gem postcode.', url: BRAND_URL, }, cues: { - hook: "You can't view your way to the right area.", + hook: "A postcode's reputation is priced in. Its value isn't.", brief: - 'So start with the brief. Price, commute, schools, even how quiet the street is.', - search: 'One search, and England shrinks to the postcodes that fit.', - commute: 'Push the commute down, and the map answers in seconds.', + '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: - 'Shortlist the winners, export them, and only spend Saturdays where it counts.', - outro: 'Stop guessing postcodes. Start choosing them.', + 'Keep the best-value few, export them, and scout where it actually counts.', + outro: 'Stop paying for the name. Find the value.', }, }, }; @@ -432,8 +433,8 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard { kind: 'mapZoom', target: mapFocus, - steps: 6, - durationMs: 1800, + steps: 5, + durationMs: 1300, direction: 'out', }, ] @@ -468,17 +469,19 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard }, { text: copy.cues.outro, - gapBeforeMs: 450, + 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: 1300 }], + tail: [{ kind: 'wait', durationMs: 1700 }], }, ]; } @@ -523,8 +526,10 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig { minDurationS: 10, maxDurationS: 75, // Right-pane inspection: London map, filters applied, data pane - // open — the clearest paused-state preview. - posterTimeS: 31, + // 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, }; } @@ -537,11 +542,13 @@ function createRecordingStoryboard( formFactor: FormFactor ): Storyboard { const copy = RECORDING_LOCALIZATIONS[locale]; - // Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide - // viewport still shows an inner-London slice densely populated with - // hexagons (otherwise the visible map gets dominated by the low-density - // outer edges with no matches). - const initialZoom = formFactor === 'mobile' ? 12 : 11.5; + // 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 @@ -609,6 +616,9 @@ function createRecordingStoryboard( 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), @@ -665,6 +675,8 @@ interface DemoAdStoryboardConfig { 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. */ @@ -688,7 +700,7 @@ const AD_VIDEO: VideoConfig = { const AD_BRAND = { name: 'Perfect Postcode', - tagline: 'Search the area before you search the listings.', + tagline: 'Find the hidden-gem postcode.', url: BRAND_URL, }; @@ -825,6 +837,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { travelTimeDragFromMin: TT_DRAG_FROM_MIN, travelTimeDragToMin: TT_DRAG_TO_MIN, brand: AD_BRAND, + matchCount: ad.matchCount, }, pre: ad.prePrime ?? AD_PRE, cues: [ @@ -844,10 +857,14 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { 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, }, ], - tail: [wait(900)], + // Hold the brand card long enough to read the URL + closer with sound off. + tail: [wait(2200)], }, ], }; @@ -865,6 +882,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-01-say-it', + matchCount: 2410, city: 'london', promptText: 'Flat under £600k, 35 min to central London, low crime, quiet street', filters: { @@ -878,23 +896,25 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], initialZoom: 10.2, posterTimeS: 8, - outroLine: 'Your area is on this map. Go find it.', + outroLine: 'The underpriced twin is on this map. Find it.', cues: [ { - text: 'This is my whole house brief. One sentence.', - caption: 'One sentence. Every postcode.', + 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: "And that's every postcode in England that fits it.", + 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: 'Now I scroll a map, not nine hundred listings.', + text: 'Same schools, same commute, the price quietly drops nearby.', + caption: 'The cheaper twin nearby', during: [mapZoomIn(1400, 4)], tail: [wait(400)], }, @@ -907,6 +927,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-02-twenty-minute-map', + matchCount: 38600, city: 'london', promptText: 'Within an hour of central London', filters: {}, @@ -915,7 +936,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], initialZoom: 10.2, posterTimeS: 9, - outroLine: 'Check the commute before you fall in love.', + outroLine: 'The commute is priced in. The bargain is not.', prePrime: [ { kind: 'clearVignette', durationMs: 0 }, { kind: 'cursorScale', scale: 0, durationMs: 0 }, @@ -932,17 +953,19 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ cues: [ { text: 'Every colour on this map is a commute to central London.', - caption: 'The 20-minute map', + 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: "If it's not lit, you'd live on the train. Now you know before you view.", + 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)], }, @@ -955,6 +978,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-03-postcode-files', + matchCount: 1290, city: 'london', initialFilters: { 'Estimated current price': [0, 700000], @@ -962,7 +986,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, initialZoom: 10.6, posterTimeS: 11, - outroLine: "Read the area's file before you book the viewing.", + outroLine: 'Every postcode, proven on the numbers, not its reputation.', prePrime: [ { kind: 'clearVignette', durationMs: 0 }, { kind: 'cursorScale', scale: 0, durationMs: 0 }, @@ -971,8 +995,8 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], cues: [ { - text: 'Estate agents say, up and coming. The data is more specific.', - caption: 'Every postcode has a file', + 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 @@ -991,11 +1015,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, { 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: 'Thirty seconds here saves you a Saturday on the wrong street.', + 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)], }, @@ -1007,6 +1033,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-04-quiet-streets', + matchCount: 6420, city: 'london', promptText: 'Quiet London street under 55 decibels, low crime', filters: { @@ -1015,21 +1042,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, initialZoom: 10.6, posterTimeS: 7, - outroLine: 'Quiet is searchable now.', + outroLine: 'The quiet street nobody bid up.', cues: [ { text: 'Listing photos are silent. Main roads are not.', - caption: "You can't hear a photo", + 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: "The quiet streets were always there. Now they're searchable.", + text: 'Nearby, just as quiet, and overlooked.', + caption: 'Quiet, and overlooked', during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, @@ -1041,6 +1070,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-05-school-run', + matchCount: 3380, city: 'leeds', promptText: 'Family home in Leeds under £350k, good primary schools, low crime', filters: { @@ -1049,24 +1079,26 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ 'Serious crime (avg/yr)': [0, 30], }, initialZoom: 11.0, - posterTimeS: 7, - outroLine: 'Pick the area first. Then the house.', + posterTimeS: 13, + outroLine: 'The schools are real. The premium is optional.', cues: [ { - text: 'Good primary school, low crime, under three hundred and fifty. The actual family brief.', - caption: 'The school-run map', + 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.', + text: 'Everything still on the map passes all three filters.', + caption: 'Passes all three', during: [submitSettled(1400)], tail: [sheetDown(800), wait(250)], }, { - text: 'No more falling for a house, then googling the catchment at midnight.', + 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)], }, @@ -1079,6 +1111,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-06-waitrose-test', + matchCount: 880, city: 'london', promptText: 'Walking distance to a Waitrose, a tube station and a park', filters: { @@ -1088,21 +1121,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, initialZoom: 10.4, posterTimeS: 7, - outroLine: 'Filter for the life, not just the floor plan.', + outroLine: 'Same Waitrose. Same tube. Cheaper postcode.', cues: [ { - text: "Near a Waitrose, a tube stop and a park. Yes, that's a real search.", + 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, cut down to the postcodes that live the way you do.', + 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: 'Snobby? Maybe. Efficient? Extremely.', + text: 'Same amenities, lower price nearby.', + caption: 'Same life, lower price', during: [mapZoomIn(1400, 4)], tail: [wait(400)], }, @@ -1114,6 +1149,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- { name: 'ad-07-renters-map', + matchCount: 2160, city: 'london', promptText: 'Rent under £1,600, 30 min to central London, quiet street', filters: { @@ -1124,22 +1160,24 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ { mode: 'transit', slug: 'london', label: 'Central London', max: 30 }, ], initialZoom: 10.3, - posterTimeS: 7, - outroLine: 'Know the postcode before the viewing queue.', + posterTimeS: 13, + outroLine: 'The name costs more. The street does not.', cues: [ { - text: 'Rent under sixteen hundred, half an hour to work, on a quiet street.', - caption: 'Renters get a map too', + 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: 'Every letting site shows you flats. This shows you areas.', + 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: 'Turn up to the right viewings, and skip the rest.', + text: 'Same commute, same quiet, lower rent nearby.', + caption: 'Same street, lower rent', during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, @@ -1147,10 +1185,11 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ }, // ------------------------------------------------------------------- - // 08 — Value. £9.99 against the real cost of a wasted viewing. + // 08 — Value. The cost of overpaying for a name vs the one-off fee. // ------------------------------------------------------------------- { - name: 'ad-08-cheap-insurance', + name: 'ad-08-value', + matchCount: 1540, city: 'london', promptText: 'Under £500k, 35 min to central London, low crime, good schools', filters: { @@ -1163,21 +1202,23 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ ], initialZoom: 10.4, posterTimeS: 7, - outroLine: 'Skip the wrong viewings.', + outroLine: 'Buy value, not reputation.', cues: [ { - text: 'A bad viewing costs a train ticket and half a weekend.', - caption: '£9.99 vs a wasted Saturday', + 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: 'This costs nine ninety-nine, and tells you where not to go.', + 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: 'Cheap insurance, for the biggest purchase of your life.', + text: 'Pay once, find the bargain, stop overpaying for the name.', + caption: 'Find the value instead', during: [mapZoomIn(1600, 5)], tail: [wait(400)], },