From d3418c67cc655e61bc9572b2bd8e3043735e7d78 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 10 Jun 2026 21:28:19 +0100 Subject: [PATCH] Improve videos --- video/render.sh | 24 +- video/src/browser.ts | 7 +- video/src/dashboard.ts | 55 +- video/src/dom.ts | 110 +-- video/src/preflight.ts | 77 ++- video/src/record.ts | 25 + video/src/routes.ts | 9 + video/src/runner.ts | 77 ++- video/src/script.ts | 54 +- video/src/storyboard.ts | 1401 +++++++++++++++++---------------------- video/src/timeline.ts | 18 +- 11 files changed, 988 insertions(+), 869 deletions(-) diff --git a/video/render.sh b/video/render.sh index 8d49663..53a7d86 100755 --- a/video/render.sh +++ b/video/render.sh @@ -337,19 +337,20 @@ if [ "$DO_AUDIO" = "1" ]; then # it across the rest of the storyboards by copying _reference.wav + # _reference.meta.json into their audio dirs before their synth runs. # synth.py's _resolve_reference() reuses a matching cached reference - # as long as the meta block (instruct/language/seed/etc.) matches — - # which it always does, because every ad shares AD_VOICE. + # as long as the meta block (instruct/language/seed/etc.) matches. + # + # We copy ONLY the reference, never the cue wavs or index.json. Copying + # the whole audio dir (as an earlier version did) overwrote each later + # storyboard's cached index.json with the FIRST storyboard's, which + # forced a full re-synth on every run — and in multi-voice sets (the + # localized homepage demos: en/de/zh/hi) it clobbered correct localized + # audio. With a reference-only copy: same-voice sets reuse the reference + # (meta matches); different-voice sets re-mint their own (meta mismatch), + # and in both cases an up-to-date cached index.json lets synth skip. shared_ref_wav="" shared_ref_meta="" - shared_audio_dir="" for sb in "${STORYBOARDS[@]}"; do - if [ -n "$shared_audio_dir" ] && [ -d "$shared_audio_dir" ]; then - mkdir -p "output/$sb/audio" - for cached_audio_file in "$shared_audio_dir"/*.wav "$shared_audio_dir"/*.json; do - [ -f "$cached_audio_file" ] || continue - cp -f "$cached_audio_file" "output/$sb/audio/$(basename "$cached_audio_file")" - done - elif [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then + if [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then mkdir -p "output/$sb/audio" cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav" cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json" @@ -365,9 +366,6 @@ if [ "$DO_AUDIO" = "1" ]; then shared_ref_meta="output/$sb/audio/_reference.meta.json" say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set" fi - if [ -z "$shared_audio_dir" ] && [ -s "output/$sb/audio/index.json" ]; then - shared_audio_dir="output/$sb/audio" - fi done fi diff --git a/video/src/browser.ts b/video/src/browser.ts index 59660ac..fa838e5 100644 --- a/video/src/browser.ts +++ b/video/src/browser.ts @@ -34,8 +34,11 @@ export async function launchRecordingBrowser( '--enable-gpu-rasterization', '--enable-zero-copy', '--disable-software-rasterizer', - '--disable-frame-rate-limit', - '--disable-gpu-vsync', + // NOTE: --disable-frame-rate-limit / --disable-gpu-vsync used to be + // here for screencast smoothness, but with the host GPU nearly full + // the uncapped render loop starved the renderer (ERR_INSUFFICIENT_ + // RESOURCES + ~31s page stalls on the 1080p take). Vsync-limited + // 60fps is plenty for the 50fps output. '--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', diff --git a/video/src/dashboard.ts b/video/src/dashboard.ts index 249f481..1c75f4d 100644 --- a/video/src/dashboard.ts +++ b/video/src/dashboard.ts @@ -66,10 +66,19 @@ export class DashboardRecorder { private selectionStatsVersion = 0; private lastHexagons: HexagonSnapshot | null = null; private lastPostcodes: PostcodeSnapshot | null = null; + private lastRequestedMapBounds: string | null = null; constructor(private readonly page: Page) { page.on('request', (request) => { - if (classifyApiRequest(request.url())) this.pending.add(request); + const kind = classifyApiRequest(request.url()); + if (kind) this.pending.add(request); + if (kind === 'hexagons' || kind === 'postcodes') { + try { + this.lastRequestedMapBounds = new URL(request.url()).searchParams.get('bounds'); + } catch { + /* ignore */ + } + } }); page.on('requestfinished', (request) => this.pending.delete(request)); page.on('requestfailed', (request) => this.pending.delete(request)); @@ -90,6 +99,28 @@ export class DashboardRecorder { await this.waitForStable({ afterMapVersion, timeoutMs }); } + /** + * Best-effort wait for tracked API traffic to go quiet (250ms of no + * pending requests and no loading indicator). Unlike waitForStable this + * never throws — it simply returns at the deadline. Used before computing + * hexagon click targets so the projection runs against the response for + * the CURRENT viewport rather than one captured mid-animation. + */ + async waitForApiIdle(timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + let stableSince: number | null = null; + while (Date.now() < deadline) { + const idle = this.pending.size === 0 && (await this.loadingIndicatorsHidden()); + if (idle) { + stableSince ??= Date.now(); + if (Date.now() - stableSince >= 250) return; + } else { + stableSince = null; + } + await this.page.waitForTimeout(100); + } + } + async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise { await this.page .locator(SELECTION_PANE_SELECTOR) @@ -99,6 +130,22 @@ export class DashboardRecorder { } async visibleHexagonTargets(limit = 8): Promise { + // Empty responses don't replace the snapshot (parse* skips them), so a + // snapshot whose bounds differ from the latest REQUEST means the current + // view has zero matching features and we'd be projecting stale data. + // Surface that loudly — it almost always means the storyboard's filters + // emptied the area it zoomed into. + const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds; + if (snapshotBounds && this.lastRequestedMapBounds) { + const snapKey = `${snapshotBounds.south},${snapshotBounds.west},${snapshotBounds.north},${snapshotBounds.east}`; + if (snapKey !== this.lastRequestedMapBounds) { + console.log( + `[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` + + `vs latest request ${this.lastRequestedMapBounds}) — the current view ` + + `likely has no matching features; clicks may land on empty map.` + ); + } + } const postcodeTargets = await this.visiblePostcodeTargets(limit); if (postcodeTargets.length > 0) return postcodeTargets; @@ -188,10 +235,14 @@ export class DashboardRecorder { height: number; }): Promise<{ top: number; bottom: number; left: number; right: number }> { let bottomClear = mapBox.y + mapBox.height - 115; + // Short timeout: on desktop the MobileBottomSheet doesn't exist, and a + // bare boundingBox() would block for Playwright's default 30s before + // the catch fires (this masqueraded as a "renderer freeze" in every + // desktop take that used hex() targets). const sheet = await this.page .locator('section[class*="rounded-t-2xl"]') .first() - .boundingBox() + .boundingBox({ timeout: 250 }) .catch(() => null); if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) { bottomClear = sheet.y - 16; diff --git a/video/src/dom.ts b/video/src/dom.ts index 650a0e8..5a9eae8 100644 --- a/video/src/dom.ts +++ b/video/src/dom.ts @@ -9,7 +9,10 @@ import type { AdScene, AdScenePanel } from './script.js'; * the Node side. That keeps a single source of truth — Playwright's real mouse * — and the visual is pure CSS, animated by the browser's compositor. */ -export async function installCursor(page: Page): Promise { +export async function installCursor( + page: Page, + style: 'arrow' | 'touch' = 'arrow' +): Promise { await page.addStyleTag({ content: ` *, *::before, *::after { cursor: none !important; } @@ -30,6 +33,12 @@ export async function installCursor(page: Page): Promise { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35)); } #__demo-cursor.click { scale: 0.85; } + /* Touch mode: a soft fingertip dot centred on the contact point. */ + #__demo-cursor.touch { + width: 34px; height: 34px; + transform-origin: 17px 17px; + } + #__demo-cursor.touch.click { scale: 0.7; } .__demo-ripple { position: fixed; @@ -72,49 +81,54 @@ export async function installCursor(page: Page): Promise { #__demo-vignette.gone { opacity: 0; } /* - * Caption positioning rules of thumb: + * Caption = short HOOK CHIP, not a transcript. Cues carry an optional + * ≤6-word caption that complements the narration (the spoken line is + * never rendered). Because the text is short, the chip can be loud + * (big type, accent bar) without covering the product. + * + * Positioning rules of thumb: * Vertical (9:16) cuts MUST keep the caption inside the top ~62% of * the viewport. TikTok, Reels, and Shorts overlay their own chrome * across the bottom ~30%, so anything below y=68% gets eaten by * the platform UI. Mobile dashboard captures also have a sheet * covering the bottom half, so a low caption sits over filter * controls rather than over the map. - * Horizontal (16:9) cuts can use the classic lower-third instead. + * Horizontal (16:9) cuts use a compact lower-third chip instead. * The body class is set once at recorder setup (setAspectClass) so * every cue inherits the right positioning. */ #__demo-caption { position: fixed; left: 50%; - transform: translate(-50%, 28px); + transform: translate(-50%, 24px); width: max-content; - max-width: min(1160px, 86vw); - padding: 22px 30px; - border-radius: 22px; - background: rgba(2, 6, 23, 0.92); - backdrop-filter: blur(20px) saturate(1.1); - -webkit-backdrop-filter: blur(20px) saturate(1.1); + max-width: min(900px, 88vw); + padding: 14px 22px; + border-radius: 14px; + background: rgba(2, 6, 23, 0.9); + backdrop-filter: blur(16px) saturate(1.1); + -webkit-backdrop-filter: blur(16px) saturate(1.1); color: #ffffff; font: - 800 36px/1.22 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", + 850 34px/1.15 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; - letter-spacing: -0.005em; + letter-spacing: -0.01em; text-align: center; text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55); box-shadow: - 0 22px 60px rgba(0, 0, 0, 0.55), - inset 0 0 0 1.5px rgba(255, 255, 255, 0.16); + 0 16px 44px rgba(0, 0, 0, 0.5), + inset 0 0 0 1.5px rgba(45, 212, 191, 0.45); z-index: 2147483641; opacity: 0; pointer-events: none; transition: - opacity 280ms ease-out, - transform 320ms cubic-bezier(0.22, 1, 0.36, 1); + opacity 240ms ease-out, + transform 300ms cubic-bezier(0.22, 1, 0.36, 1); white-space: normal; } - /* Horizontal default: classic lower-third. */ + /* Horizontal: compact lower-third chip. */ body.__demo-aspect-horizontal #__demo-caption { - bottom: 7%; + bottom: 6%; } body.__demo-aspect-horizontal #__demo-caption.placement-side { left: auto; @@ -122,21 +136,22 @@ export async function installCursor(page: Page): Promise { bottom: 10%; transform: translate(28px, 0); max-width: min(560px, 30vw); - padding: 18px 22px; - border-radius: 18px; + padding: 14px 20px; + border-radius: 14px; font-size: 26px; line-height: 1.18; text-align: left; } - /* Vertical default: upper-third. Kept compact so the map remains the - primary visual in the social ad cuts. */ + /* Vertical: upper area, clear of platform chrome and the bottom sheet. + 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: 7%; - max-width: min(820px, 82vw); - font-size: 27px; - font-weight: 750; + top: 8%; + max-width: 88vw; + font-size: 30px; + font-weight: 850; padding: 12px 18px; - border-radius: 14px; + border-radius: 12px; } #__demo-caption.visible { opacity: 1; @@ -496,14 +511,26 @@ export async function installCursor(page: Page): Promise { `, }); - await page.evaluate(() => { + await page.evaluate((style) => { const cursor = document.createElement('div'); cursor.id = '__demo-cursor'; - cursor.innerHTML = ` - - - `; + // Hotspot: arrow tip sits 2px in from the SVG corner; the touch dot is + // centred on the contact point. + const hot = style === 'touch' ? 17 : 2; + if (style === 'touch') { + cursor.classList.add('touch'); + cursor.innerHTML = ` + + + `; + } else { + cursor.innerHTML = ` + + + `; + } document.body.appendChild(cursor); const vignette = document.createElement('div'); @@ -517,7 +544,7 @@ export async function installCursor(page: Page): Promise { window.addEventListener( 'mousemove', (e) => { - cursor.style.transform = `translate(${e.clientX - 2}px, ${e.clientY - 2}px)`; + cursor.style.transform = `translate(${e.clientX - hot}px, ${e.clientY - hot}px)`; }, { passive: true, capture: true } ); @@ -525,7 +552,7 @@ export async function installCursor(page: Page): Promise { __demoMoveCursor?: (x: number, y: number, durationMs: number) => void; }).__demoMoveCursor = (x, y, durationMs) => { cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`; - cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`; + cursor.style.transform = `translate(${x - hot}px, ${y - hot}px)`; window.setTimeout(() => { cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out'; }, durationMs + 40); @@ -549,7 +576,7 @@ export async function installCursor(page: Page): Promise { () => cursor.classList.remove('click'), { passive: true, capture: true } ); - }); + }, style); } export async function clearVignette(page: Page): Promise { @@ -897,8 +924,15 @@ export async function zoomTo( const { scale, focusX, focusY, durationMs = 1100 } = opts; const transitionMs = Math.round(durationMs); const viewport = page.viewportSize() ?? { width: 1920, height: 1080 }; - const dx = viewport.width / 2 - scale * focusX; - const dy = viewport.height / 2 - scale * focusY; + // Clamp the pan so the scaled app always covers the whole viewport. + // Without this, focusing an element near a screen edge drags the app + // off-frame and exposes the dark backdrop (the old cold-open showed a + // third of the frame as void). For scale ≥ 1 the translation must stay + // within [size·(1−scale), 0] on each axis. + const clampPan = (value: number, span: number): number => + scale >= 1 ? Math.min(0, Math.max(span * (1 - scale), value)) : value; + const dx = clampPan(viewport.width / 2 - scale * focusX, viewport.width); + const dy = clampPan(viewport.height / 2 - scale * focusY, viewport.height); await page.evaluate( ({ dx, dy, scale, transitionMs }) => { const wrap = document.getElementById('__demo-zoom-wrap'); diff --git a/video/src/preflight.ts b/video/src/preflight.ts index 4b6e0e9..7c9e1d2 100644 --- a/video/src/preflight.ts +++ b/video/src/preflight.ts @@ -59,9 +59,79 @@ function emitScript(storyboard: Storyboard): string { return path; } -function main(): void { +/** + * Validate every stubbed/initial filter name and travel-destination slug + * against the LIVE API. Wrong names don't error in the app — they silently + * no-op, the map never changes, and you only find out after a full render. + * Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh + * has already health-checked it by the time preflight runs). + */ +async function validateAgainstLiveApi(): Promise { + const apiBase = process.env.API_URL ?? process.env.APP_URL; + if (!apiBase) { + console.warn('[preflight] no API_URL/APP_URL set — skipping live filter validation'); + return; + } + + let featureNames: Set; + try { + const res = await fetch(`${apiBase}/api/features`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { + groups: { features: { name: string }[] }[]; + }; + featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name))); + } catch (err) { + console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}) — skipping validation`); + return; + } + + const problems: string[] = []; + for (const sb of storyboards) { + const filterNames = [ + ...Object.keys(sb.content.stubbedFilters), + ...Object.keys(sb.content.initialFilters ?? {}), + ]; + for (const name of filterNames) { + if (!featureNames.has(name)) { + problems.push(`[${sb.name}] filter "${name}" is not a live /api/features name`); + } + } + } + + const modes = new Set( + storyboards.flatMap((sb) => sb.content.stubbedTravelTimeFilters.map((tt) => tt.mode)) + ); + for (const mode of modes) { + try { + const res = await fetch(`${apiBase}/api/travel-destinations?mode=${mode}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { destinations: { slug: string }[] }; + const slugs = new Set(body.destinations.map((d) => d.slug)); + for (const sb of storyboards) { + for (const tt of sb.content.stubbedTravelTimeFilters) { + if (tt.mode === mode && !slugs.has(tt.slug)) { + problems.push(`[${sb.name}] travel destination "${tt.slug}" (${mode}) not on live API`); + } + } + } + } catch (err) { + console.warn(`[preflight] could not validate travel destinations for ${mode}: ${err}`); + } + } + + if (problems.length > 0) { + for (const p of problems) console.error(`[preflight] FAIL: ${p}`); + process.exit(1); + } + console.log('[preflight] all stubbed filter names and travel slugs match the live API'); +} + +async function main(): Promise { if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); + await validateAgainstLiveApi(); + for (const sb of storyboards) emitScript(sb); // Index for shell loops — each entry has every field render.sh needs to @@ -84,4 +154,7 @@ function main(): void { console.log(`[preflight] wrote storyboard index → ${indexPath}`); } -main(); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/video/src/record.ts b/video/src/record.ts index 49ea163..bc1e2d7 100644 --- a/video/src/record.ts +++ b/video/src/record.ts @@ -51,6 +51,31 @@ async function recordOne(storyboard: Storyboard): Promise { const u = r.url(); if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`); }); + // Debug instrumentation: per-second request-rate histogram by path bucket. + // The desktop take intermittently freezes for ~30s after heavy filter + // churn; this shows whether a request flood is what's choking the page. + if (process.env.VIDEO_DEBUG_REQ_RATE === '1') { + const counts = new Map(); + page.on('request', (r) => { + let bucket: string; + try { + const u = new URL(r.url()); + bucket = u.pathname.split('/').slice(0, 3).join('/'); + } catch { + bucket = 'other'; + } + counts.set(bucket, (counts.get(bucket) ?? 0) + 1); + }); + const timer = setInterval(() => { + if (counts.size === 0) return; + const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); + console.log( + `[req-rate] ${top.map(([k, v]) => `${k}=${v}`).join(' ')}` + ); + counts.clear(); + }, 1000); + timer.unref(); + } await installDemoRoutes(page, storyboard); const ctx = await prepareTimeline(page, storyboard); diff --git a/video/src/routes.ts b/video/src/routes.ts index 49b77e6..167496d 100644 --- a/video/src/routes.ts +++ b/video/src/routes.ts @@ -13,6 +13,15 @@ export function dashboardUrl(storyboard: Storyboard): string { lon: String(view.lon), zoom: String(view.zoom), }); + // Cold-open filters: applied through the URL so the first frame already + // shows a filtered map. Numeric features use name:min:max, enums name:a|b. + for (const [name, value] of Object.entries(storyboard.content.initialFilters ?? {})) { + const entry = + typeof value[0] === 'number' + ? `${name}:${value[0]}:${value[1]}` + : `${name}:${(value as string[]).join('|')}`; + params.append('filter', entry); + } for (const tt of storyboard.content.stubbedTravelTimeFilters) { params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`); } diff --git a/video/src/runner.ts b/video/src/runner.ts index 8d27e23..9707586 100644 --- a/video/src/runner.ts +++ b/video/src/runner.ts @@ -101,7 +101,10 @@ async function runCue( videoTimeMs: cursor.ms + leadInMs, durationMs: measuredAudioMs, }); - await showCaption(ctx.page, cue.text, cue.captionPlacement); + // The spoken line is never rendered; only an explicit short caption is. + if (cue.caption) { + await showCaption(ctx.page, cue.caption, cue.captionPlacement); + } const during = cue.during ?? []; const declaredSum = during.reduce((s, a) => s + a.durationMs, 0); @@ -126,7 +129,9 @@ async function runCue( cursor.ms += duringElapsed; } - await hideCaption(ctx.page); + if (cue.caption) { + await hideCaption(ctx.page); + } for (const step of cue.tail ?? []) { cursor.ms += await runStep(ctx, step); @@ -185,6 +190,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { } case 'click': { const selectionVersion = ctx.dashboard.getSelectionStatsVersion(); + // Hexagon targets are projected from the latest map response; make + // sure that response corresponds to the settled viewport (a zoom in + // the previous cue may still have postcode fetches in flight). + if (step.target.kind === 'hexagon') { + await ctx.dashboard.waitForApiIdle(3000); + } const candidates = step.target.kind === 'hexagon' && step.waitForSelectionReady ? await ctx.dashboard.visibleHexagonTargets(4) @@ -228,7 +239,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { const mapVersion = ctx.dashboard.getMapDataVersion(); const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA; const handled = await ctx.page.evaluate( - async ({ x, y, steps, durationMs, direction }) => { + async ({ x, y, steps, durationMs, direction, center }) => { const root = document.querySelector('.maplibregl-map') as HTMLElement | null; const fiberKey = root ? Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactFiber$')) @@ -264,6 +275,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { zoom: number, options: { around?: unknown; duration?: number; essential?: boolean } ) => void; + easeTo?: (options: { + center?: unknown; + zoom?: number; + duration?: number; + essential?: boolean; + }) => void; }; if ( typeof mapApi.getCanvas !== 'function' || @@ -281,7 +298,16 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { const minZoom = mapApi.getMinZoom?.() ?? 0; const maxZoom = mapApi.getMaxZoom?.() ?? 22; const targetZoom = Math.max(minZoom, Math.min(maxZoom, mapApi.getZoom() + zoomDelta)); - mapApi.zoomTo(targetZoom, { around, duration: durationMs, essential: true }); + if (center && typeof mapApi.easeTo === 'function') { + mapApi.easeTo({ + center: around, + zoom: targetZoom, + duration: durationMs, + essential: true, + }); + } else { + mapApi.zoomTo(targetZoom, { around, duration: durationMs, essential: true }); + } await new Promise((resolve) => window.setTimeout(resolve, durationMs)); return true; }, @@ -291,6 +317,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { steps: step.steps, durationMs: step.durationMs, direction: step.direction, + center: step.center ?? false, } ); @@ -363,6 +390,41 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { case 'scrollPane': await scrollPaneTo(ctx.page, step.selector, step.top); return; + case 'dragSheet': { + const sheet = ctx.page.locator('section[class*="rounded-t-2xl"]').first(); + const sheetBox = await sheet.boundingBox().catch(() => null); + if (!sheetBox) return; // desktop layout — nothing to drag + const handle = ctx.page + .locator('section[class*="rounded-t-2xl"] [class*="touch-none"]') + .first(); + const handleBox = (await handle.boundingBox().catch(() => null)) ?? { + x: sheetBox.x, + y: sheetBox.y + 4, + width: sheetBox.width, + height: 20, + }; + const viewport = ctx.page.viewportSize() ?? { width: 540, height: 960 }; + const startX = handleBox.x + handleBox.width / 2; + const startY = handleBox.y + handleBox.height / 2; + // The sheet resizes so its top tracks the pointer (the component + // clamps to its own min/max). Aim the pointer where the handle would + // sit when the sheet covers `toHeightFrac` of the viewport. + const handleOffset = startY - sheetBox.y; + const endY = viewport.height * (1 - step.toHeightFrac) + handleOffset; + const approachMs = Math.min(260, Math.max(140, Math.round(step.durationMs * 0.25))); + const dragMs = Math.max(180, step.durationMs - approachMs - 80); + await smoothMove(ctx.page, ctx.cursor, { x: startX, y: startY }, { durationMs: approachMs }); + await ctx.page.mouse.down(); + await smoothMove( + ctx.page, + { x: startX, y: startY }, + { x: startX, y: endY }, + { durationMs: dragMs, realMouse: true } + ); + await ctx.page.mouse.up(); + ctx.cursor = { x: startX, y: endY }; + return; + } case 'openFilterGroup': // Click is idempotent: if the group is already expanded, the click // would collapse it — which we don't want. Detect via aria-expanded @@ -375,6 +437,9 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { trigger.click(); }, step.selector); return; + case 'pressKey': + await ctx.page.keyboard.press(step.key); + return; } } @@ -395,7 +460,9 @@ async function resolveTarget( y: Math.round(Math.min(1, Math.max(0, target.yFrac)) * viewport.height), }; } - const box = await ctx.page.locator(target.selector).boundingBox(); + // Bounded wait: a storyboard pointing at a missing element should fail in + // seconds with a clear error, not stall for Playwright's default 30s. + const box = await ctx.page.locator(target.selector).boundingBox({ timeout: 5000 }); if (!box) throw new Error(`No bounding box for selector: ${target.selector}`); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } diff --git a/video/src/script.ts b/video/src/script.ts index 87160ed..3a35cfd 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -143,6 +143,13 @@ export type Activity = steps: number; durationMs: number; direction?: 'in' | 'out'; + /** + * When true, the camera eases so `target` ends up at the canvas + * centre (instead of staying pinned at its current screen position). + * Use for "zoom into the best match" shots so the payoff lands + * centre-frame rather than wherever the match happened to be. + */ + center?: boolean; waitForMapSettled?: boolean; timeoutMs?: number; } @@ -178,28 +185,45 @@ export type Activity = * scroll through the property-stats drawer after a postcode click. */ | { kind: 'scrollPane'; selector: string; top: number; durationMs: number } + /** + * Drag the MobileBottomSheet's grab handle so the sheet ends up covering + * `toHeightFrac` of the viewport height. The component clamps to its own + * min/max, so 0 collapses the sheet to its grab-handle sliver and the map + * becomes the hero of the frame. No-op when the sheet isn't in the DOM + * (desktop layouts). + */ + | { kind: 'dragSheet'; toHeightFrac: number; durationMs: number } /** * Click the header of a collapsible filter group (e.g. "Transport", * "Schools") so the cards beneath it become visible. Idempotent — * if the group is already open this is a no-op click. */ - | { kind: 'openFilterGroup'; selector: string; durationMs: number }; + | { kind: 'openFilterGroup'; selector: string; durationMs: number } + /** Press a keyboard key (e.g. 'Escape' to dismiss a modal). */ + | { kind: 'pressKey'; key: string; durationMs: number }; /** * A narration cue + the activities that play alongside it. * - * gapBeforeMs : silent wall-time before the caption appears (= silence in + * text : the SPOKEN narration line (TTS input). Never rendered on + * screen — what's said must not also be shown. + * caption : optional SHORT on-screen chip (≤6 words) complementing the + * narration. Distinct from `text` by design: visual hooks + * ("You can't hear a photo") and stats live here, the story + * lives in the voice. Omit for no on-screen text. + * gapBeforeMs : silent wall-time before the cue starts (= silence in * audio between the previous cue ending and this one). - * during : activities that play WHILE the caption is on screen. The + * during : activities that play WHILE the cue's audio runs. The * sum of declared durations must be ≤ the measured audio - * duration; the runner pads short blocks so the caption stays - * on for the full cue. Sum > measured is a hard error. - * tail : activities that run AFTER the caption hides, before the - * next cue's gapBefore starts. Use it for dwells/transitions - * that aren't tied to spoken words. + * duration; the runner pads short blocks so the cue lasts + * the full audio. Sum > measured is a hard error. + * tail : activities that run AFTER the cue's audio (and caption) + * end, before the next cue's gapBefore starts. Use it for + * dwells/transitions that aren't tied to spoken words. */ export interface Cue { text: string; + caption?: string; /** Optional cue-specific caption layout for shots where the default lower-third hides the product. */ captionPlacement?: 'side'; gapBeforeMs: number; @@ -223,6 +247,14 @@ export interface VideoConfig { * If unset, the viewport comes from `viewportFor(aspect)`. */ viewport?: { width: number; height: number }; + /** + * Visual style of the injected cursor. 'arrow' (default) renders the + * classic pointer — right for desktop demos. 'touch' renders a soft + * fingertip dot, which reads as a phone gesture on 9:16 mobile cuts + * (an arrow cursor on a phone-shaped video instantly breaks the + * illusion that you're watching the mobile product). + */ + cursorStyle?: 'arrow' | 'touch'; /** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */ webmBitrate: string; /** Final fps after the trim/resample pass. */ @@ -273,6 +305,12 @@ export interface ContentConfig { /** Cold-open zoom multiplier on the AI card. */ aiZoomScale: number; initialMapView: { lat: number; lon: number; zoom: number }; + /** + * Filters applied via the dashboard URL before the recording starts, so a + * storyboard can cold-open on an already-filtered map without burning + * screen time on a type+submit. Keys must be real /api/features names. + */ + initialFilters?: Record; stubbedFilters: Record; stubbedTravelTimeFilters: TravelTimeFilter[]; travelTimeCardSelector: string; diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 1f45a4a..8d436a3 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -13,12 +13,24 @@ type FormFactor = 'desktop' | 'mobile'; /** * The list of demo videos to render, in order. * - * Each entry is a fully self-contained Storyboard: video knobs (aspect, - * bitrate, fps), voice persona (Qwen3-TTS instruct + language + sampling), - * stubbed AI response, brand strings, AND the cue list. There is no shared - * global state. The exported array can contain generated variants, so a - * shared visual storyboard can render once per language without repeating - * its activity sequence. + * 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` @@ -28,42 +40,54 @@ type FormFactor = 'desktop' | 'mobile'; * 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. Inter-cue - * spacing is controlled here via `gapBeforeMs` (silence in audio) plus - * optional `tail` activities (visual movement after the caption hides, - * before the next cue's gap). + * length, padding short `during` blocks with a trailing wait. */ -// Cold-open wrapper-zoom into the AI card. Desktop only — the card is -// small relative to the 1920x1080 viewport, so leaning in sells the -// "natural-language search" beat. On mobile the AI card is already the -// most prominent thing on screen (it sits at the top of the bottom -// sheet which covers ~44% of the viewport), so we skip the wrapper zoom -// entirely — see buildPre(). -const AI_ZOOM_SCALE_DESKTOP = 2.05; +// School-count features as served by live /api/features TODAY. The data +// pipeline has already moved to modelled catchment counts ("Good+ primary +// school catchments"), so flip these two constants when that deploy lands +// on prod — preflight will fail loudly if the names drift from the API. +const SCHOOL_GOOD_PRIMARY = 'Good+ primary schools within 2km'; +const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary schools within 2km'; + +// 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; -const TT_DRAG_TO_MIN = 20; +// 25 (not 20): tight enough that the drag visibly prunes the map, loose +// enough that street-level Manchester 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 cue-4 zoom-in lands. Desktop targets a fixed pixel +// 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 ~44% of the 540x960 viewport is occupied by the -// MobileBottomSheet, so a centre-screen point would click through to the -// sheet, not the map). +// 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.3); +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"])'; -// Mobile mapZoom intensity. Keep mobile below the old 18-step drill that -// overshot into featureless street-level tiles, but make the homepage pass -// visibly break from city blobs into postcode/street scale. const MOBILE_MAP_ZOOM_STEPS = 9; const MOBILE_MAP_ZOOM_MS = 2200; -const DESKTOP_MAP_ZOOM_STEPS = 18; -const DESKTOP_MAP_ZOOM_MS = 4300; +// 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'; @@ -76,6 +100,10 @@ interface RecordingLocalization { 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; @@ -83,14 +111,14 @@ interface RecordingLocalization { url: string; }; cues: { - describe: string; - prompt: string; - dashboard: string; - filters: string; - zoom: string; + hook: string; + brief: string; + search: string; + commute: string; + streets: string; open: string; - details: string; shortlist: string; + outro: string; }; } @@ -98,7 +126,6 @@ interface RecordingLocalization { // 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 BRAND_DOMAIN = 'perfect-postcode.co.uk'; const RECORDING_LOCALIZATIONS: Record = { en: { @@ -111,9 +138,11 @@ const RECORDING_LOCALIZATIONS: Record = voiceReferenceText: "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.", promptText: - 'First home under £315k, 35 min to Manchester, good schools, check crime, road noise, tree cover, fast broadband', + 'First home under £315k, 35 min to Manchester, good schools, low crime, quiet street, fast broadband', travelTimeLabel: 'Manchester city centre', exportButtonTitle: 'Export to Excel', + exportConfirmLabel: 'Export', + closeDrawerLabel: 'Close drawer', colourMapTitle: 'Colour map', brand: { name: 'Perfect Postcode', @@ -121,18 +150,17 @@ const RECORDING_LOCALIZATIONS: Record = url: BRAND_URL, }, cues: { - describe: 'A Manchester first-time buyer wants to stop wasting Saturdays on the wrong streets.', - prompt: - 'They type the whole brief: under £315k, thirty-five minutes to town, good schools, low crime, quieter roads, trees, and fast broadband.', - dashboard: - 'The map keeps only the postcodes that match. The rest of the country drops away.', - filters: - 'Now tweak it: cut the commute to twenty minutes and colour the map by travel time.', - zoom: 'Zoom in until the blobs become streets, parks, and postcode blocks.', - open: 'Open one block that still passes the filters.', - details: - 'On the right, you can see why it passed: journey time, listing links, Street View, sold prices, schools, crime, the noise number, and the tree score.', - shortlist: 'Export those postcodes and only search there.', + hook: "You can't view your way to the right area.", + 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.', + 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.', }, }, de: { @@ -147,25 +175,27 @@ const RECORDING_LOCALIZATIONS: Record = promptText: 'Wohnung unter £300k, 35 Min. nach Manchester, gute Schulen, niedrige Kriminalität, ruhige Straßen', travelTimeLabel: 'Stadtzentrum Manchester', - exportButtonTitle: 'Als Excel exportieren', + exportButtonTitle: 'Nach Excel exportieren', + exportConfirmLabel: 'Exportieren', + closeDrawerLabel: 'Drawer schließen', colourMapTitle: 'Karte einfärben', brand: { name: 'Perfect Postcode', - tagline: 'Wissen, wo du suchen solltest, bevor Inserate deine Suche bestimmen.', + tagline: 'Erst die Gegend, dann das Haus.', url: BRAND_URL, }, cues: { - describe: 'Wähle kein Zuhause durch endloses Scrollen.', - prompt: - 'Beschreibe, was dir wichtig ist. Budget, Pendelzeit, Schulen, alles.', - dashboard: 'Die Karte zeigt jede passende Postleitzahl in ganz England.', - filters: 'Ein Regler bewegt sich. Die Karte antwortet sofort.', - zoom: 'Jetzt von der Stadtansicht bis zu echten Straßen zoomen.', - open: 'Öffne einen Treffer und sieh, warum er übrig bleibt.', - details: - 'Öffne eine Postleitzahl. Preise. Schulen. Kriminalität. Lärm. Alles auf einer Karte.', + hook: 'Das richtige Viertel findest du nicht durch Besichtigungen.', + 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.', + 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.', shortlist: - 'Mit dieser Auswahl zu den Inseraten. Du weißt jetzt, wo du suchen sollst.', + '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.', }, }, zh: { @@ -179,21 +209,23 @@ const RECORDING_LOCALIZATIONS: Record = promptText: '30万英镑以内的公寓,35分钟到曼彻斯特,学校好,犯罪率低,街道安静', travelTimeLabel: '曼彻斯特市中心', exportButtonTitle: '导出为 Excel', - colourMapTitle: '为地图着色', + exportConfirmLabel: '导出', + closeDrawerLabel: '关闭侧栏', + colourMapTitle: '地图着色', brand: { name: 'Perfect Postcode', - tagline: '先知道该看哪里,再让房源牵着你走。', + tagline: '先选对街区,再选房子。', url: BRAND_URL, }, cues: { - describe: '别再靠刷房源挑家了。', - prompt: '用日常话告诉地图你想要的家。预算、通勤、学校,什么都行。', - dashboard: '地图点亮每一个符合条件的英格兰邮编。', - filters: '动一个滑块,地图立刻给答案。', - zoom: '现在从城市范围放大到真实街道。', - open: '打开一个匹配项,看看它为什么留下来。', - details: '打开任意邮编。成交价、学校、犯罪率、噪音,一目了然。', - shortlist: '带着这份清单去房源网站。现在你知道该在哪儿找了。', + hook: '光靠看房,看不出哪个街区适合你。', + brief: '先列条件。预算、通勤、学校,甚至街道安不安静。', + search: '搜索一次,全英格兰只剩下符合条件的邮编。', + commute: '把通勤时间调短,地图几秒内就给出答案。', + streets: '放大到街道层面,好街区自己浮现出来。', + open: '点开一个,它会摆出证据。成交价、学校、犯罪率、噪音、宽带。', + shortlist: '把心仪的区域导出,周末只去值得去的地方。', + outro: '别再猜邮编,开始挑邮编。', }, }, hi: { @@ -207,27 +239,38 @@ const RECORDING_LOCALIZATIONS: Record = "Welcome to the demonstration. This is the narrator voice you'll hear throughout the video.", promptText: 'Flat under £300k, 35 min to Manchester, good schools, low crime, quiet streets', travelTimeLabel: 'Manchester city centre', - exportButtonTitle: 'Excel में निर्यात करें', - colourMapTitle: 'नक्शे को रंगें', + exportButtonTitle: 'Excel में export करें', + exportConfirmLabel: 'Export', + closeDrawerLabel: 'ड्रॉअर बंद करें', + colourMapTitle: 'मानचित्र रंगें', brand: { name: 'Perfect Postcode', - tagline: 'Know where to look before listings take over.', + tagline: 'Find the area before the house.', url: BRAND_URL, }, cues: { - describe: "Don't pick a home by scrolling listings.", - prompt: - 'Describe what you want. Budget, commute, schools, whatever matters.', - dashboard: 'The map lights up with every postcode in England that fits.', - filters: 'Move one slider. The map answers instantly.', - zoom: 'Now zoom in from the city pattern to actual streets.', - open: 'Open one match and see why it made the cut.', - details: 'Open any postcode. Sold prices. Schools. Crime. Noise. All on one screen.', - shortlist: 'Take your shortlist to the listings. Now you know where to search.', + hook: "You can't view your way to the right area.", + 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.', + 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.', }, }, }; +/** + * 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'; @@ -235,49 +278,18 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard 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 postcodeDemoTarget = isMobile - ? vfrac(320 / 540, 255 / 960) - : vfrac(1087 / 1920, 520 / 1080); - const openPostcodeTarget = postcodeDemoTarget; - const zoomPostcodeTarget = postcodeDemoTarget; - const cursorParkTarget = isMobile ? vfrac(0.12, 0.61) : vfrac(0.12, 0.18); 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")'; - const shortlistActivities: Storyboard['cues'][number]['during'] = - formFactor === 'desktop' - ? [ - { kind: 'zoomReset', durationMs: 800 }, - { - kind: 'click', - target: el(`button[title="${copy.exportButtonTitle}"]`), - durationMs: 800, - }, - ] - : [ - { - kind: 'click', - target: el('button[aria-label="Close drawer"]'), - durationMs: 650, - }, - { - kind: 'mapZoom', - target: mapFocus, - steps: MOBILE_MAP_ZOOM_STEPS, - durationMs: MOBILE_MAP_ZOOM_MS, - direction: 'out', - waitForMapSettled: true, - timeoutMs: 12000, - }, - ]; - return [ { - text: copy.cues.describe, + // 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: 700 }] + ? [{ kind: 'wait', durationMs: 500 }] : [ { kind: 'zoomTo', @@ -289,148 +301,172 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard tail: [{ kind: 'wait', durationMs: 150 }], }, { - text: copy.cues.prompt, - gapBeforeMs: 0, + text: copy.cues.brief, + gapBeforeMs: 200, during: [ { kind: 'type', selector: '[data-tutorial="ai-filters"] textarea', text: copy.promptText, - durationMs: 4300, + durationMs: 4200, }, ], tail: [{ kind: 'wait', durationMs: 120 }], }, { - text: copy.cues.dashboard, - gapBeforeMs: 300, + text: copy.cues.search, + gapBeforeMs: 250, during: [ { kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', - durationMs: 2200, + durationMs: 2000, waitForMapSettled: true, timeoutMs: 15000, }, - { kind: 'zoomReset', durationMs: 900 }, + ...(isMobile ? [] : [{ kind: 'zoomReset', durationMs: 800 } as Activity]), ], - tail: [{ kind: 'wait', durationMs: 300 }], + tail: [{ kind: 'wait', durationMs: 250 }], }, - { - text: copy.cues.filters, - gapBeforeMs: 500, + 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: 1800, + durationMs: 1600, }, - { kind: 'click', target: colourTravelTime, durationMs: 750 }, - ], - tail: [{ kind: 'wait', durationMs: 350 }], - }, - - { - text: copy.cues.zoom, - gapBeforeMs: 500, - during: [ - { kind: 'cursorScale', scale: 1.4, durationMs: 200 }, - { - kind: 'mapZoom', - target: zoomPostcodeTarget, - steps: mapZoomSteps, - durationMs: mapZoomMs, - }, - ], - tail: [ - { kind: 'moveCursor', target: cursorParkTarget, durationMs: 250 }, - { kind: 'wait', durationMs: 120 }, - ], - }, - - { - text: copy.cues.open, - gapBeforeMs: 200, - during: [ - { - kind: 'click', - target: openPostcodeTarget, - durationMs: 1200, - waitForSelectionReady: true, - timeoutMs: 6000, - }, - { kind: 'cursorScale', scale: 1, durationMs: 250 }, + { kind: 'click', target: colourTravelTime, durationMs: 700 }, ], tail: [{ kind: 'wait', durationMs: 300 }], }, - { - text: copy.cues.details, - captionPlacement: isMobile ? undefined : 'side', + 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 ? [ { - kind: 'scrollPane', - selector: HOMEPAGE_RIGHT_PANE_SELECTOR, - top: 430, - durationMs: 900, - }, - { + // 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(definingCharacteristicsSelector), + target: el(`button[aria-label="${copy.closeDrawerLabel}"]`), durationMs: 650, - timeoutMs: 700, + timeoutMs: 1500, }, { - kind: 'scrollPane', - selector: HOMEPAGE_RIGHT_PANE_SELECTOR, - top: 700, - durationMs: 850, + kind: 'mapZoom', + target: mapFocus, + steps: 6, + durationMs: 1800, + direction: 'out', }, ] : [ + { kind: 'zoomReset', durationMs: 800 }, { - kind: 'zoomTo', - target: el(HOMEPAGE_RIGHT_PANE_SELECTOR), - scale: 1.35, - durationMs: 950, - }, - { - kind: 'scrollPane', - selector: HOMEPAGE_RIGHT_PANE_SELECTOR, - top: 360, - durationMs: 850, - }, - { + // 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(definingCharacteristicsSelector), - durationMs: 650, - timeoutMs: 700, + target: el(`button[title="${copy.exportButtonTitle}"]`), + durationMs: 800, + timeoutMs: 1500, }, + { kind: 'wait', durationMs: 400 }, { - kind: 'scrollPane', - selector: HOMEPAGE_RIGHT_PANE_SELECTOR, - top: 920, - durationMs: 850, + // 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: 700 }], + 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.shortlist, - gapBeforeMs: 500, - during: shortlistActivities, - tail: [{ kind: 'wait', durationMs: 650 }], - }, - - { - text: `${copy.brand.name}. ${copy.brand.tagline}`, - gapBeforeMs: 600, + text: copy.cues.outro, + gapBeforeMs: 450, during: [ { kind: 'showOutro', @@ -440,18 +476,12 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard durationMs: 0, }, ], - tail: [{ kind: 'wait', durationMs: 1500 }], + tail: [{ kind: 'wait', durationMs: 1300 }], }, ]; } -function buildPre(formFactor: FormFactor): Storyboard['pre'] { - if (formFactor === 'mobile') { - return [ - { kind: 'clearVignette', durationMs: 0 }, - { kind: 'wait', durationMs: 120 }, - ]; - } +function buildPre(): Storyboard['pre'] { return [ { kind: 'clearVignette', durationMs: 0 }, { kind: 'wait', durationMs: 120 }, @@ -470,29 +500,29 @@ function buildVideoConfig(formFactor: FormFactor): VideoConfig { // genuinely looks mobile-styled, not desktop in portrait. viewport: { width: 540, height: 960 }, captureScale: 2, - // 540x960 needs less bitrate than 1080p — 4M is comfortable. + cursorStyle: 'touch', webmBitrate: '4M', outputFps: 50, minDurationS: 10, maxDurationS: 75, - // Click + drawer-open moment — landing on the right-pane reveal. - posterTimeS: 12, + // 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, - // 8M is enough for 1920x1080 at captureScale=1; bump to 18M when - // captureScale > 1 (supersampled) — see render.sh history if reviving - // higher-quality cuts. + cursorStyle: 'arrow', webmBitrate: '8M', outputFps: 50, minDurationS: 10, maxDurationS: 75, - // Right-pane inspection (~16s into the trimmed timeline) is the - // clearest paused-state preview: Manchester map, filters applied, - // right pane populated, larger narration caption visible. - posterTimeS: 16, + // Right-pane inspection: Manchester map, filters applied, data pane + // open — the clearest paused-state preview. + posterTimeS: 31, }; } @@ -536,20 +566,22 @@ function createRecordingStoryboard( content: { promptText: copy.promptText, appLanguage: copy.appLanguage, - // aiZoomScale is irrelevant on mobile (pre skips the zoomTo) but - // we keep the field populated so it stays a required Config. 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 (verified against the running server's schema). + // from /api/features (preflight validates against the live server). stubbedFilters: { 'Property type': ['Flats/Maisonettes', 'Semi-Detached'], 'Estimated current price': [0, 315000], - 'Serious crime per 1k residents (avg/yr)': [0, 70], - 'Good+ primary schools within 2km': [1, 10], - 'Noise (dB)': [50, 70], - 'Street tree density percentile': [25, 100], - 'Max available download speed (Mbps)': [100, 1000], + // Loose enough to keep the Manchester 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. @@ -567,7 +599,7 @@ function createRecordingStoryboard( travelTimeDragToMin: TT_DRAG_TO_MIN, brand: copy.brand, }, - pre: buildPre(formFactor), + pre: buildPre(), cues: createCues(locale, formFactor), }; } @@ -583,28 +615,25 @@ const DEMO_STORYBOARDS: Storyboard[] = RECORDING_LOCALES.flatMap((locale) => RECORDING_FORM_FACTORS.map((formFactor) => createRecordingStoryboard(locale, formFactor)) ); -type CityKey = - | 'manchester' - | 'birmingham' - | 'bristol' - | 'london' - | 'leeds' - | 'liverpool' - | 'sheffield'; +// --------------------------------------------------------------------------- +// 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'; -/** - * Per-cue spec for a demo ad. Each cue carries its caption and the live- - * product activities that must happen while it is on screen. Unlike the - * old overlay-only ads, here the dashboard does the visual work — type, - * drag, zoom, click. Scene overlays (when present at all) are short - * hooks layered transparently over the product, not full-frame scrims. - */ interface DemoAdCueConfig { text: string; + caption?: string; gapBeforeMs?: number; - /** Activities that fit inside the caption's audio window. */ + /** Activities that fit inside the cue's audio window. */ during?: Activity[]; - /** Activities that run after the caption fades, before the next gap. */ + /** Activities that run after the cue's audio ends, before the next cue. */ tail?: Activity[]; } @@ -612,55 +641,36 @@ interface DemoAdStoryboardConfig { name: string; city: CityKey; /** - * The text typed into the AI box. Used by the runner when a `type` - * activity is in this ad's cues; the AI stub returns `filters` / - * `travelTimeFilters` regardless of the typed text, so the prompt - * value here is purely for visual veracity (matches what the - * filters imply). + * 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; - /** - * Activities to run before the cue loop starts. Default is now an - * empty no-op (clearVignette + hide-cursor only) so narration starts - * IMMEDIATELY at t=0. Older ads that needed a silent type+submit - * pre-prime can pass `prePrime: adPrime(promptText)` explicitly. - */ + /** Activities to run before the cue loop starts (silent — keep short). */ prePrime?: Activity[]; - /** - * Optional spoken line during the outro card. Defaults to - * "Find your area first." — kept short and DIFFERENT from the - * visual brand+URL so we don't double-up "Perfect Postcode - * perfect-postcode dot co dot uk". - */ - outroLine?: string; + /** Spoken line during the outro card. Must NOT repeat the card's text. */ + outroLine: string; cues: DemoAdCueConfig[]; } -/** - * Ads are always vertical 9:16 with a true mobile look. CSS viewport - * 540x960 falls under Tailwind's `md:` 768px breakpoint, so the - * frontend renders the mobile layout (compact header, bottom-sheet - * filters, mobile typography). captureScale 2 gives Chromium a - * 1080x1920 device-pixel surface internally, and `recordedSizeFor` (in - * script.ts) now multiplies viewport × captureScale so Playwright - * records the final mp4 at sharp 1080x1920 — proper "mobile look, - * higher DPR" rather than a soft 540x960 upscale. - */ 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 that include a mapZoom drift longer than - // declared (deep-zoom tile load on maplibre eats a second or two); - // 35s comfortably covers the worst case while staying within - // TikTok / Reels / Shorts upload limits. + // Generous upper bound — ads with a deep mapZoom drift a second or two + // past their declared budgets while tiles load. maxDurationS: 35, posterTimeS: 5, }; @@ -668,22 +678,19 @@ const AD_VIDEO: VideoConfig = { const AD_BRAND = { name: 'Perfect Postcode', tagline: 'Search the area before you search the listings.', - // dom.ts strips the protocol for display in the outro CTA, so this - // renders as "perfect-postcode.co.uk" without us having to special-case - // the field — and other consumers (analytics, links) still get a full URL. url: BRAND_URL, }; /** - * Ad voice persona. The SAME config is used across every ad so the - * voice timbre stays consistent across the set. (Synth still mints a - * separate `_reference.wav` per storyboard, but with identical instruct - * / language / referenceText / seed the cloned voice converges.) + * 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, calm, conversational pace. ' + - 'No salesy enthusiasm, no exaggeration. Short sentences. Plain delivery.', + '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.', @@ -693,71 +700,91 @@ const AD_VOICE = { }; /** - * Initial map centres for each ad. - * - * On the mobile 540x960 viewport the MapLibre canvas fills the full - * viewport, but the MobileBottomSheet overlays its bottom ~50% and the - * post-click drawer overlays more. So the VISIBLE map band is roughly - * y = 50–470 (≈ 44 % of the viewport), with its visual centre at y ≈ 260. - * The map's geographic centre, however, renders at the canvas centre - * (y = 480) — hidden by the sheet. - * - * To make the advertised city centre land at the VISIBLE centre (y ≈ 260), - * we shift each map's centre lat roughly 0.13° SOUTH of the true city - * centre. The true centre then re-projects upward by ~220 px and ends up - * squarely inside the visible map band. The shift was derived from - * Mercator pixels-per-degree at lat 51 and zoom 10–11; for cities at - * higher latitudes the same px offset corresponds to a slightly smaller - * deg shift, but ±0.02° tolerance is fine. + * 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.3772, lon: -0.1276, zoom: 10.5 }, - leeds: { lat: 53.6708, lon: -1.5491, zoom: 11.1 }, - liverpool: { lat: 53.2784, lon: -2.9916, zoom: 11.1 }, - sheffield: { lat: 53.2511, lon: -1.4701, zoom: 11.2 }, + london: { lat: 51.4272, lon: -0.1276, zoom: 10.4 }, + leeds: { lat: 53.7308, lon: -1.5491, zoom: 11.0 }, }; -const AD_DEFAULT_FILTERS: Record = { - 'Estimated current price': [0, 350000], - 'Serious crime per 1k residents (avg/yr)': [0, 70], - 'Outstanding primary schools within 2km': [0, 10], -}; +// -- small helpers used by the per-ad cue lists ------------------------------- -const linger = (durationMs = 360): Activity[] => [{ kind: 'wait', durationMs }]; +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, +}); /** - * A short, near-invisible "warm-up" run before the cue loop: - * 1. Drop the vignette and the cursor (no mouse hover in ads). - * 2. Type the prompt FAST (600ms — feels like a quick paste). - * 3. Submit, so by the time cue 0 starts, the AI filter response - * has come back and the map is already filtered. - * - * Result: every ad starts on a FILTERED map (hexagons / postcodes - * already lit up). The first caption is therefore reading against - * a visual that's already telling part of the story. + * 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). */ -function adPrime(promptText: string): Activity[] { - return [ - { kind: 'clearVignette', durationMs: 0 }, - { kind: 'cursorScale', scale: 0, durationMs: 0 }, - { kind: 'wait', durationMs: 200 }, - { - kind: 'type', - selector: '[data-tutorial="ai-filters"] textarea', - text: promptText, - durationMs: 600, - }, - { kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1100 }, - // Give the map a moment to re-fetch hexagons + redraw before the - // first narrated cue starts. /api/hexagons typically returns in - // 500-900ms on prod; 1100ms is a comfortable cushion for the - // post-filter redraw + first paint. - { kind: 'wait', durationMs: 1100 }, - ]; -} +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 = @@ -769,21 +796,6 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { zoom: ad.initialZoom ?? CITY_VIEWS[ad.city].zoom, }; - // Default pre: just clear the vignette and hide the cursor — no - // silent type+submit. Narration starts at the very first frame. - // Ads that need filters applied invisibly can pass an explicit - // `prePrime` (e.g. adPrime(promptText)) to do a fast type+submit - // before cue 0 — but the audio cost (~3s of silence) is the - // trade-off. - const pre = - ad.prePrime !== undefined - ? ad.prePrime - : [ - { kind: 'clearVignette' as const, durationMs: 0 }, - { kind: 'cursorScale' as const, scale: 0, durationMs: 0 }, - { kind: 'wait' as const, durationMs: 80 }, - ]; - return { name: ad.name, locale: 'en', @@ -794,7 +806,8 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { appLanguage: 'en', aiZoomScale: 1, initialMapView, - stubbedFilters: ad.filters ?? AD_DEFAULT_FILTERS, + initialFilters: ad.initialFilters, + stubbedFilters: ad.filters ?? {}, stubbedTravelTimeFilters: ad.travelTimeFilters ?? [], travelTimeCardSelector: TT_CARD_SELECTOR, travelTimeSliderMax: TT_SLIDER_MAX, @@ -802,20 +815,18 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { travelTimeDragToMin: TT_DRAG_TO_MIN, brand: AD_BRAND, }, - pre, + pre: ad.prePrime ?? AD_PRE, cues: [ ...ad.cues.map((cue, index) => ({ text: cue.text, - gapBeforeMs: cue.gapBeforeMs ?? (index === 0 ? 0 : 220), + caption: cue.caption, + gapBeforeMs: cue.gapBeforeMs ?? (index === 0 ? 0 : 250), during: cue.during, - tail: cue.tail ?? linger(), + tail: cue.tail ?? [wait(300)], })), { - // The outro card already shows the brand name + URL visually, so - // the spoken line is a short closer that complements rather than - // echoing the brand. Each storyboard can override via outroLine. - text: ad.outroLine ?? 'Find your area first.', - gapBeforeMs: 200, + text: ad.outroLine, + gapBeforeMs: 250, during: [ { kind: 'showOutro', @@ -825,543 +836,339 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { durationMs: 0, }, ], - tail: [{ kind: 'wait', durationMs: 900 }], + tail: [wait(900)], }, ], }; } -// -- 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 = 2800): Activity => ({ - kind: 'type', - selector: AI_TEXTAREA, - text, - durationMs, -}); -const submitAct = (durationMs = 1300): Activity => ({ - kind: 'submitForm', - formSelector: AI_FORM, - durationMs, -}); -const ttDragAct = (toMin: number, durationMs = 1400): 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 mapZoomIn = (durationMs = 1400, steps = 5): Activity => ({ - kind: 'mapZoom', - target: vfrac(0.5, 0.28), - steps, - durationMs, -}); -/** - * Click the centre of the highest-priority visible postcode polygon from - * the latest map response. Robust to zoom drift in a way that a fixed - * vfrac target is not: at zoom 14+ a viewport-fraction click can land on - * a road or river, and the drawer never opens. `hex()` (resolved by - * DashboardRecorder.visibleHexagonTargets) scans the page's recorded - * postcode response, picks one that's visible AND clear of the bottom - * sheet / side rails, and returns its centroid in pixels. - */ -const clickHex = (durationMs = 900): Activity => ({ - kind: 'click', - target: hex(), - durationMs, -}); - // --------------------------------------------------------------------------- -// Helpers for the per-ad cue lists. -// --------------------------------------------------------------------------- - -// Scrollable container in the mobile bottom sheet (the section that holds -// the active filter cards). scrollPane finds the nearest scrollable -// ancestor of this selector and scrolls it. -const FILTER_PANE_SELECTOR = 'section[class*="rounded-t-2xl"]'; -// Right-pane / drawer used after a postcode click for stats. -const RIGHT_PANE_SELECTOR = '[data-tutorial="right-pane"]'; - -const scrollFilters = (top: number, durationMs = 700): Activity => ({ - kind: 'scrollPane', - selector: FILTER_PANE_SELECTOR, - top, - durationMs, -}); -const scrollDrawer = (top: number, durationMs = 700): Activity => ({ - kind: 'scrollPane', - selector: RIGHT_PANE_SELECTOR, - top, - durationMs, -}); - -// Common London centre — shifted south so the Thames + central boroughs -// sit in the visible upper-half of the mobile viewport (above the sheet). -const LONDON_VIEW = { lat: 51.4672, lon: -0.1276, zoom: 10.5 }; - -// --------------------------------------------------------------------------- -// The ad set. -// -// Every entry is a self-contained 9:16 mobile vertical video. Narration -// starts AT THE FIRST FRAME (no silent pre-prime). The outro card shows -// the brand name + URL visually; the spoken outro line is therefore a -// SHORT closer like "Find your area first." — never "Perfect Postcode -// perfect-postcode dot co dot uk". -// -// Each ad uses filters RELEVANT to its topic (not just price+schools), -// and includes at least one visible map interaction (type, drag, -// mapZoom, click, scroll-filters, scroll-drawer). Most ads are London- -// focused; we keep one Leeds variant for city variety and two themed -// ads (Waitrose distance, % Reform vote share) covering edge-case -// filters available on prod. +// The ad set. 8 concepts, each: hook chip + one product interaction. +// All filter names verified against live /api/features. // --------------------------------------------------------------------------- const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // ------------------------------------------------------------------- - // 01 — Search by sentence. Type the prompt on camera, narration runs - // simultaneously. Filters relevant: price + commute + crime + noise. + // 01 — The hero feature: the whole brief in one sentence. + // Cold open ON the typing; map reveal as the payoff. // ------------------------------------------------------------------- { - name: 'ad-01-london-prompt', + name: 'ad-01-say-it', city: 'london', - promptText: - 'London flat under £600k, 35 min to centre, lower crime, lower noise', + 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 per 1k residents (avg/yr)': [0, 50], - 'Noise (dB)': [0, 58], + 'Serious crime (avg/yr)': [0, 35], + 'Noise (dB)': [0, 60], }, travelTimeFilters: [ - { mode: 'transit', slug: 'london', label: 'London city centre', max: 35 }, + { mode: 'transit', slug: 'london', label: 'Central London', max: 35 }, ], initialZoom: 10.2, posterTimeS: 8, + outroLine: 'Your area is on this map. Go find it.', cues: [ { - text: 'Stop searching listing by listing. Search by the area brief.', - during: [typeAct( - 'London flat under £600k, 35 min to centre, lower crime, lower noise', - 2800 - )], - tail: [wait(200)], + text: 'This is my whole house brief. One sentence.', + caption: 'One sentence. Every postcode.', + during: [ + typeAct('Flat under £600k, 35 min to central London, low crime, quiet street', 2600), + ], + tail: [wait(150)], }, { - text: 'Price, commute, crime and noise land on the map together.', - during: [submitAct(1100)], - tail: [wait(700)], + text: "And that's every postcode in England that fits it.", + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], }, { - text: 'Every lit postcode is somewhere worth checking first.', - tail: [wait(600)], + text: 'Now I scroll a map, not nine hundred listings.', + during: [mapZoomIn(1400, 4)], + tail: [wait(400)], }, ], }, // ------------------------------------------------------------------- - // 02 — One slider. TT filter applied via URL only (no AI submit) so - // the cold-open shows a filtered map and cue 1 immediately drags it. + // 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-london-slider', + name: 'ad-02-twenty-minute-map', city: 'london', - promptText: 'London within 40 minutes of centre', + promptText: 'Within an hour of central London', filters: {}, travelTimeFilters: [ - { mode: 'transit', slug: 'london', label: 'London city centre', max: 40 }, + { mode: 'transit', slug: 'london', label: 'Central London', max: 60 }, + ], + initialZoom: 10.2, + posterTimeS: 9, + outroLine: 'Check the commute before you fall in love.', + 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 }, ], - initialZoom: 10.4, - posterTimeS: 5.5, cues: [ { - text: 'Your commute limit should change the map, not your patience.', - tail: [wait(200)], + text: 'Every colour on this map is a commute to central London.', + caption: 'The 20-minute map', + during: [wait(400)], + tail: [wait(150)], }, { - text: 'Drag forty minutes down to fifteen minutes.', - during: [ttDragAct(15, 1900)], - tail: [wait(700)], + text: "Here's what twenty minutes actually leaves you.", + during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)], + tail: [touchHide(), sheetDown(800), wait(300)], }, { - text: 'The reachable postcodes disappear in front of you.', - tail: [wait(600)], + text: "If it's not lit, you'd live on the train. Now you know before you view.", + during: [mapZoomIn(1300, 3)], + tail: [wait(400)], }, ], }, // ------------------------------------------------------------------- - // 03 — Click a real postcode. Deeper zoom (10 wheel steps) past the - // hex aggregation into actual postcode polygons, then click, then - // scroll the property drawer to surface the data. + // 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-london-zoom-postcode', + name: 'ad-03-postcode-files', city: 'london', - promptText: 'Family home in London, decent schools nearby', - filters: { + initialFilters: { 'Estimated current price': [0, 700000], - 'Outstanding primary schools within 2km': [1, 10], - }, - initialZoom: 10.5, - posterTimeS: 10, - cues: [ - { - text: 'Type a family brief and watch matching areas appear.', - during: [typeAct('Family home in London, decent schools nearby', 2400), submitAct(900)], - tail: [wait(500)], - }, - { - text: 'Zoom from area patterns into actual postcodes.', - during: [mapZoomIn(3000, 10)], - tail: [wait(400)], - }, - { - text: 'Tap one for sold prices and street-level context.', - during: [ - { kind: 'cursorScale', scale: 1.3, durationMs: 200 }, - clickHex(900), - ], - tail: [ - wait(900), - scrollDrawer(450, 700), - wait(1100), - { kind: 'cursorScale', scale: 0, durationMs: 200 }, - ], - }, - ], - }, - - // ------------------------------------------------------------------- - // 04 — London 400k. Type+submit, scroll the filter pane to show all - // the applied filter cards (variety vs ad-01 which doesn't scroll). - // ------------------------------------------------------------------- - { - name: 'ad-04-london-400k', - city: 'london', - promptText: - 'Flat in London under £400k, 30 min to centre, lower crime', - filters: { - 'Property type': ['Flats/Maisonettes'], - 'Estimated current price': [0, 400000], - 'Serious crime per 1k residents (avg/yr)': [0, 55], - }, - travelTimeFilters: [ - { mode: 'transit', slug: 'london', label: 'London city centre', max: 30 }, - ], - initialZoom: 10.3, - posterTimeS: 6, - cues: [ - { - text: 'London under four hundred thousand, with a thirty minute commute.', - during: [typeAct( - 'Flat in London under £400k, 30 min to centre, lower crime', - 2800 - ), submitAct(900)], - tail: [wait(400)], - }, - { - text: 'The active filters stack up as the map tightens.', - during: [scrollFilters(280, 900)], - tail: [wait(600)], - }, - { - text: 'Now the cheap-looking areas have to pass the brief.', - tail: [wait(500)], - }, - ], - }, - - // ------------------------------------------------------------------- - // 05 — Two streets apart. Product-led now: noise + crime filters are - // typed and submitted on screen instead of masking the product with - // generic street photos. - // ------------------------------------------------------------------- - { - name: 'ad-05-two-streets-apart', - city: 'london', - promptText: 'Quiet London streets, lower noise, lower serious crime', - filters: { - 'Noise (dB)': [0, 55], - 'Serious crime per 1k residents (avg/yr)': [0, 45], + [SCHOOL_OUTSTANDING_PRIMARY]: [1, 10], }, initialZoom: 10.6, - posterTimeS: 4, + posterTimeS: 11, + outroLine: "Read the area's file before you book the viewing.", + prePrime: [ + { kind: 'clearVignette', durationMs: 0 }, + { kind: 'cursorScale', scale: 0, durationMs: 0 }, + sheetDown(700), + { kind: 'wait', durationMs: 200 }, + ], cues: [ { - text: 'Two streets can look identical in a listing photo.', - during: [typeAct( - 'Quiet London streets, lower noise, lower serious crime', - 2500 - ), submitAct(900)], - tail: [wait(400)], + text: 'Estate agents say, up and coming. The data is more specific.', + caption: 'Every postcode has a file', + 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: 'Filter noise and serious crime before you book a viewing.', - during: [scrollFilters(220, 800)], - tail: [wait(500)], + text: 'Sold prices, schools, crime, even Street View, for this exact postcode.', + during: [touchShow(), tapHex(1000), scrollDrawer(420, 850)], + tail: [wait(200)], }, { - text: 'Now the quieter pockets are the ones left on screen.', - during: [mapZoomIn(1300, 4)], - tail: [wait(600)], + text: 'Thirty seconds here saves you a Saturday on the wrong street.', + during: [scrollDrawer(820, 900), touchHide()], + tail: [wait(350)], }, ], }, // ------------------------------------------------------------------- - // 06 — Commute tax. Starts on the live commute layer and immediately - // proves the point with the travel-time slider. + // 04 — Noise. The strongest single line in the set; the product proves it. // ------------------------------------------------------------------- { - name: 'ad-06-london-commute-tax', + name: 'ad-04-quiet-streets', city: 'london', - promptText: 'London within sixty minutes', - filters: {}, - travelTimeFilters: [ - { mode: 'transit', slug: 'london', label: 'London city centre', max: 60 }, - ], - initialZoom: 10.0, - posterTimeS: 4, - cues: [ - { - text: 'A cheap home gets expensive when the commute is wrong.', - tail: [wait(300)], - }, - { - text: 'Drag sixty minutes down to twenty and watch the map shrink.', - during: [ttDragAct(20, 1900)], - tail: [wait(700)], - }, - { - text: 'That weekly time bill is visible before the viewing.', - tail: [wait(600)], - }, - ], - }, - - // ------------------------------------------------------------------- - // 07 — Quiet near London. Uses the real prod Noise (dB) feature. - // ------------------------------------------------------------------- - { - name: 'ad-07-quiet-near-london', - city: 'london', - promptText: 'Quieter London, lower road noise, good transit', + promptText: 'Quiet London street under 55 decibels, low crime', filters: { 'Noise (dB)': [0, 55], - 'Estimated current price': [0, 700000], + 'Serious crime (avg/yr)': [0, 35], }, - travelTimeFilters: [ - { mode: 'transit', slug: 'london', label: 'London city centre', max: 45 }, - ], - initialZoom: 10.4, - posterTimeS: 4, - cues: [ - { - text: 'Quiet near London is searchable, not just hopeful.', - during: [typeAct('Quieter London, lower road noise, good transit', 2500), submitAct(900)], - tail: [wait(400)], - }, - { - text: 'Filter for noise alongside price and travel time.', - during: [scrollFilters(220, 800)], - tail: [wait(500)], - }, - { - text: 'The calmer pockets show up before you go anywhere.', - tail: [wait(500)], - }, - ], - }, - - // ------------------------------------------------------------------- - // 08 — The postcode comes with the keys. Keeps the memorable premise, - // but shows the product doing the work instead of a keys stock photo. - // ------------------------------------------------------------------- - { - name: 'ad-08-postcode-with-the-keys', - city: 'london', - promptText: 'Family London, lower crime, good schools, lower noise', - filters: { - 'Estimated current price': [0, 750000], - 'Outstanding primary schools within 2km': [1, 10], - 'Serious crime per 1k residents (avg/yr)': [0, 50], - 'Noise (dB)': [0, 58], - }, - travelTimeFilters: [ - { mode: 'transit', slug: 'london', label: 'London city centre', max: 45 }, - ], - initialZoom: 10.5, - posterTimeS: 3, - cues: [ - { - text: 'You can change the kitchen. You inherit the postcode.', - during: [typeAct( - 'Family London, lower crime, good schools, lower noise', - 2500 - ), submitAct(900)], - tail: [wait(400)], - }, - { - text: 'So check commute, crime, schools and noise first.', - during: [scrollFilters(320, 900)], - tail: [wait(500)], - }, - { - text: 'Pick the area first. The keys come second.', - during: [mapZoomIn(1200, 4)], - tail: [wait(600)], - }, - ], - }, - - // ------------------------------------------------------------------- - // 09 — Amenities. Waitrose is the memorable example, but the copy - // frames it as practical amenity filtering rather than a throwaway gag. - // ------------------------------------------------------------------- - { - name: 'ad-09-london-waitrose', - city: 'london', - promptText: - 'London postcodes near Waitrose, tube and parks under £800k', - filters: { - 'Distance to nearest Waitrose (km)': [0, 1], - 'Distance to nearest tube station (km)': [0, 1.2], - 'Distance to nearest park (km)': [0, 0.8], - 'Estimated current price': [0, 800000], - }, - initialZoom: 10.4, + initialZoom: 10.6, posterTimeS: 7, + outroLine: 'Quiet is searchable now.', cues: [ { - text: 'Amenities should be filters, not guesses from the photos.', - during: [typeAct( - 'London postcodes near Waitrose, tube and parks under £800k', - 2800 - ), submitAct(900)], + text: 'Listing photos are silent. Main roads are not.', + caption: "You can't hear a photo", + during: [typeAct('Quiet London street under 55 decibels, low crime', 2400)], + tail: [wait(150)], + }, + { + text: 'This is London under fifty-five decibels.', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: "The quiet streets were always there. Now they're searchable.", + during: [mapZoomIn(1600, 5)], tail: [wait(400)], }, - { - text: 'Waitrose, tube, parks and price can all count together.', - during: [scrollFilters(300, 900)], - tail: [wait(600)], - }, - { - text: 'Now you know which postcodes actually match that lifestyle.', - tail: [wait(500)], - }, ], }, // ------------------------------------------------------------------- - // 10 — Local politics. Matter-of-fact and product-led; lower threshold - // keeps the map populated while still surfacing the Reform UK feature. + // 05 — Families / the school run. Leeds for non-London variety. // ------------------------------------------------------------------- { - name: 'ad-10-reform-councils', + name: 'ad-05-school-run', city: 'leeds', - promptText: - 'Areas with higher Reform UK vote share and lower prices', + promptText: 'Family home in Leeds under £350k, good primary schools, low crime', filters: { - '% Reform UK': [15, 100], 'Estimated current price': [0, 350000], - }, - initialZoom: 10.5, - posterTimeS: 7, - cues: [ - { - text: 'Local politics is part of the neighbourhood data too.', - during: [typeAct( - 'Areas with higher Reform UK vote share and lower prices', - 2600 - )], - tail: [wait(300)], - }, - { - text: 'Run the filter and see which areas stay in view.', - during: [submitAct(900), scrollFilters(180, 700)], - tail: [wait(500)], - }, - { - text: 'No spin. Just another local signal before you buy.', - tail: [wait(500)], - }, - ], - }, - - // ------------------------------------------------------------------- - // 11 — Leeds for families. Non-London variety. Schools focus. - // ------------------------------------------------------------------- - { - name: 'ad-11-leeds-families', - city: 'leeds', - promptText: - 'Leeds family areas, good primary schools nearby, lower crime', - filters: { - 'Estimated current price': [0, 380000], - 'Good+ primary schools within 2km': [2, 10], - 'Serious crime per 1k residents (avg/yr)': [0, 45], + [SCHOOL_GOOD_PRIMARY]: [2, 10], + 'Serious crime (avg/yr)': [0, 30], }, initialZoom: 11.0, - posterTimeS: 6, + posterTimeS: 7, + outroLine: 'Pick the area first. Then the house.', cues: [ { - text: 'Find Leeds areas that work for the school run.', - during: [typeAct( - 'Leeds family areas, good primary schools nearby, lower crime', - 2500 - ), submitAct(900)], - tail: [wait(300)], + text: 'Good primary school, low crime, under three hundred and fifty. The actual family brief.', + caption: 'The school-run map', + during: [ + typeAct('Family home in Leeds under £350k, good primary schools, low crime', 2800), + ], + tail: [wait(150)], }, { - text: 'School quality and serious crime sit beside price.', - during: [scrollFilters(220, 800)], - tail: [wait(500)], + text: 'Everything still on the map passes all three.', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], }, { - text: 'Every lit postcode is a better place to start.', - tail: [wait(500)], + text: 'No more falling for a house, then googling the catchment at midnight.', + during: [mapZoomIn(1600, 5)], + tail: [wait(400)], }, ], }, // ------------------------------------------------------------------- - // 12 — Pricing/value. Keeps the current £9.99 founder-price hook, but - // proves value through the product instead of a static scarcity card. + // 06 — Lifestyle amenities. The Waitrose line is the hook; tube + park + // make it practical. Names match the live amenity-distance features. // ------------------------------------------------------------------- { - name: 'ad-12-pricing-scarcity', + name: 'ad-06-waitrose-test', city: 'london', - promptText: 'London under £700k, good schools, lower crime and lower noise', + promptText: 'Walking distance to a Waitrose, a tube station and a park', filters: { - 'Estimated current price': [0, 700000], - 'Outstanding primary schools within 2km': [1, 10], - 'Serious crime per 1k residents (avg/yr)': [0, 50], - 'Noise (dB)': [0, 58], + '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: 3, + posterTimeS: 7, + outroLine: 'Filter for the life, not just the floor plan.', cues: [ { - text: 'Nine ninety nine beats one wasted viewing.', - during: [typeAct( - 'London under £700k, good schools, lower crime and lower noise', - 2700 - ), submitAct(900)], + text: "Near a Waitrose, a tube stop and a park. Yes, that's a real search.", + 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.', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'Snobby? Maybe. Efficient? Extremely.', + 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', + 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: 7, + outroLine: 'Know the postcode before the viewing queue.', + cues: [ { - text: 'Use the map before spending a Saturday in the wrong area.', - during: [scrollFilters(300, 900)], - tail: [wait(500)], + text: 'Rent under sixteen hundred, half an hour to work, on a quiet street.', + caption: 'Renters get a map too', + during: [typeAct('Rent under £1,600, 30 min to central London, quiet street', 2800)], + tail: [wait(150)], }, { - text: 'The cheapest mistake is the one you skip.', - tail: [wait(600)], + text: 'Every letting site shows you flats. This shows you areas.', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'Turn up to the right viewings, and skip the rest.', + during: [mapZoomIn(1600, 5)], + tail: [wait(400)], + }, + ], + }, + + // ------------------------------------------------------------------- + // 08 — Value. £9.99 against the real cost of a wasted viewing. + // ------------------------------------------------------------------- + { + name: 'ad-08-cheap-insurance', + 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: 'Skip the wrong viewings.', + cues: [ + { + text: 'A bad viewing costs a train ticket and half a weekend.', + caption: '£9.99 vs a wasted Saturday', + 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.', + during: [submitSettled(1400)], + tail: [sheetDown(800), wait(250)], + }, + { + text: 'Cheap insurance, for the biggest purchase of your life.', + during: [mapZoomIn(1600, 5)], + tail: [wait(400)], }, ], }, diff --git a/video/src/timeline.ts b/video/src/timeline.ts index 55311f6..6d45c4b 100644 --- a/video/src/timeline.ts +++ b/video/src/timeline.ts @@ -29,16 +29,30 @@ export async function prepareTimeline( await sleep(400); await installZoomWrapper(page); - await installCursor(page); + await installCursor(page, storyboard.video.cursorStyle ?? 'arrow'); await setAspectClass(page, storyboard.video.aspect); const ctx: ScriptCtx = { page, dashboard, cursor: { x: 200, y: 240 } }; await page.mouse.move(ctx.cursor.x, ctx.cursor.y); - await prepareAiBox(ctx); + // Only pre-open the AI prompt when the storyboard actually types into it. + // Opening it unconditionally grows the mobile bottom sheet (keyboard + // avoidance) and steals the frame from the map on ads that never type. + if (storyboardTypes(storyboard)) { + await prepareAiBox(ctx); + } await sleep(80); return ctx; } +function storyboardTypes(storyboard: Storyboard): boolean { + const all = [ + ...(storyboard.pre ?? []), + ...storyboard.cues.flatMap((cue) => [...(cue.during ?? []), ...(cue.tail ?? [])]), + ...(storyboard.post ?? []), + ]; + return all.some((activity) => activity.kind === 'type'); +} + export async function runTimeline( ctx: ScriptCtx, storyboard: Storyboard