diff --git a/frontend/public/video/recording-de-mobile.jpg b/frontend/public/video/recording-de-mobile.jpg new file mode 100644 index 0000000..207ecb0 Binary files /dev/null and b/frontend/public/video/recording-de-mobile.jpg differ diff --git a/frontend/public/video/recording-de-mobile.mp4 b/frontend/public/video/recording-de-mobile.mp4 new file mode 100644 index 0000000..59654a4 Binary files /dev/null and b/frontend/public/video/recording-de-mobile.mp4 differ diff --git a/frontend/public/video/recording-de.jpg b/frontend/public/video/recording-de.jpg index e4c932f..ef2bee8 100644 Binary files a/frontend/public/video/recording-de.jpg and b/frontend/public/video/recording-de.jpg differ diff --git a/frontend/public/video/recording-de.mp4 b/frontend/public/video/recording-de.mp4 index 1397be5..a9e8f72 100644 Binary files a/frontend/public/video/recording-de.mp4 and b/frontend/public/video/recording-de.mp4 differ diff --git a/frontend/public/video/recording-hi-mobile.jpg b/frontend/public/video/recording-hi-mobile.jpg new file mode 100644 index 0000000..b44c64f Binary files /dev/null and b/frontend/public/video/recording-hi-mobile.jpg differ diff --git a/frontend/public/video/recording-hi-mobile.mp4 b/frontend/public/video/recording-hi-mobile.mp4 new file mode 100644 index 0000000..1b5dbde Binary files /dev/null and b/frontend/public/video/recording-hi-mobile.mp4 differ diff --git a/frontend/public/video/recording-hi.jpg b/frontend/public/video/recording-hi.jpg index 11f2749..a5b7978 100644 Binary files a/frontend/public/video/recording-hi.jpg and b/frontend/public/video/recording-hi.jpg differ diff --git a/frontend/public/video/recording-hi.mp4 b/frontend/public/video/recording-hi.mp4 index 65a17da..b65646a 100644 Binary files a/frontend/public/video/recording-hi.mp4 and b/frontend/public/video/recording-hi.mp4 differ diff --git a/frontend/public/video/recording-mobile.jpg b/frontend/public/video/recording-mobile.jpg new file mode 100644 index 0000000..8a555a0 Binary files /dev/null and b/frontend/public/video/recording-mobile.jpg differ diff --git a/frontend/public/video/recording-mobile.mp4 b/frontend/public/video/recording-mobile.mp4 new file mode 100644 index 0000000..41d6821 Binary files /dev/null and b/frontend/public/video/recording-mobile.mp4 differ diff --git a/frontend/public/video/recording-zh-mobile.jpg b/frontend/public/video/recording-zh-mobile.jpg new file mode 100644 index 0000000..286695c Binary files /dev/null and b/frontend/public/video/recording-zh-mobile.jpg differ diff --git a/frontend/public/video/recording-zh-mobile.mp4 b/frontend/public/video/recording-zh-mobile.mp4 new file mode 100644 index 0000000..f868da8 Binary files /dev/null and b/frontend/public/video/recording-zh-mobile.mp4 differ diff --git a/frontend/public/video/recording-zh.jpg b/frontend/public/video/recording-zh.jpg index e4a5a6c..beb7d9c 100644 Binary files a/frontend/public/video/recording-zh.jpg and b/frontend/public/video/recording-zh.jpg differ diff --git a/frontend/public/video/recording-zh.mp4 b/frontend/public/video/recording-zh.mp4 index 7ff5fd1..926c80f 100644 Binary files a/frontend/public/video/recording-zh.mp4 and b/frontend/public/video/recording-zh.mp4 differ diff --git a/frontend/public/video/recording.jpg b/frontend/public/video/recording.jpg index 65e76df..545add5 100644 Binary files a/frontend/public/video/recording.jpg and b/frontend/public/video/recording.jpg differ diff --git a/frontend/public/video/recording.mp4 b/frontend/public/video/recording.mp4 index 698c184..4e65ebc 100644 Binary files a/frontend/public/video/recording.mp4 and b/frontend/public/video/recording.mp4 differ diff --git a/video/src/browser.ts b/video/src/browser.ts index d6916b6..59660ac 100644 --- a/video/src/browser.ts +++ b/video/src/browser.ts @@ -5,7 +5,7 @@ import { type Page, } from 'playwright'; import { AUTH_STATE_PATH } from './config.js'; -import { viewportFor, type Storyboard } from './script.js'; +import { recordedSizeFor, viewportFor, type Storyboard } from './script.js'; export interface RecordingBrowser { browser: Browser; @@ -44,11 +44,12 @@ export async function launchRecordingBrowser( }); const viewport = viewportFor(storyboard.video); + const recordSize = recordedSizeFor(storyboard.video); const context = await browser.newContext({ storageState: AUTH_STATE_PATH, viewport, deviceScaleFactor: storyboard.video.captureScale, - recordVideo: { dir: opts.recordDir, size: viewport }, + recordVideo: { dir: opts.recordDir, size: recordSize }, }); await context.addInitScript((appLanguage) => { if (appLanguage) localStorage.setItem('language', appLanguage); diff --git a/video/src/dashboard.ts b/video/src/dashboard.ts index 859ff7b..9eb37d2 100644 --- a/video/src/dashboard.ts +++ b/video/src/dashboard.ts @@ -105,17 +105,18 @@ export class DashboardRecorder { const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox(); if (!mapBox) throw new Error('Map container has no bounding box'); + const clear = await this.clickableBox(mapBox); const projected = snapshot.features .filter((feature) => feature.count > 0) .map((feature) => { const point = projectFromBounds(feature, snapshot.bounds, mapBox); if (!point) return null; - const centerX = mapBox.x + mapBox.width / 2; - const centerY = mapBox.y + mapBox.height / 2; + const centerX = clear.left + (clear.right - clear.left) / 2; + const centerY = clear.top + (clear.bottom - clear.top) / 2; const distanceFromCenter = Math.hypot( - (point.x - centerX) / (mapBox.width / 2), - (point.y - centerY) / (mapBox.height / 2) + (point.x - centerX) / Math.max(1, (clear.right - clear.left) / 2), + (point.y - centerY) / Math.max(1, (clear.bottom - clear.top) / 2) ); return { h3: feature.h3, @@ -136,10 +137,10 @@ export class DashboardRecorder { ); const clearOfChrome = onScreen.filter( (target) => - target.x >= mapBox.x + 80 && - target.x <= mapBox.x + mapBox.width - 130 && - target.y >= mapBox.y + 105 && - target.y <= mapBox.y + mapBox.height - 115 + target.x >= clear.left && + target.x <= clear.right && + target.y >= clear.top && + target.y <= clear.bottom ); const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort( @@ -151,6 +152,37 @@ export class DashboardRecorder { return candidates.slice(0, limit).map(({ score: _score, ...target }) => target); } + /** + * The pixel rect inside `mapBox` that's safe to click — i.e. not under + * the dashboard's left filters pane, right details pane, or (on mobile) + * the floating MobileBottomSheet. We detect the sheet via the only + * `section.rounded-t-2xl` in the DOM and treat its top as a hard + * bottom-clear limit; without that, hex() would happily return a + * polygon hidden under the sheet on 9x16 cuts. + */ + private async clickableBox(mapBox: { + x: number; + y: number; + width: number; + height: number; + }): Promise<{ top: number; bottom: number; left: number; right: number }> { + let bottomClear = mapBox.y + mapBox.height - 115; + const sheet = await this.page + .locator('section[class*="rounded-t-2xl"]') + .first() + .boundingBox() + .catch(() => null); + if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) { + bottomClear = sheet.y - 16; + } + return { + top: mapBox.y + 105, + bottom: bottomClear, + left: mapBox.x + 80, + right: mapBox.x + mapBox.width - 130, + }; + } + private async captureResponse(response: Response): Promise { const kind = classifyApiRequest(response.url()); if (!kind || !response.ok()) return; @@ -249,6 +281,7 @@ export class DashboardRecorder { const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox(); if (!mapBox) throw new Error('Map container has no bounding box'); + const clear = await this.clickableBox(mapBox); const projected = snapshot.features .filter((feature) => feature.properties.count > 0) @@ -256,11 +289,11 @@ export class DashboardRecorder { const [lon, lat] = feature.properties.centroid; const point = projectFromBounds({ lat, lon }, snapshot.bounds, mapBox); if (!point) return null; - const centerX = mapBox.x + mapBox.width / 2; - const centerY = mapBox.y + mapBox.height / 2; + const centerX = clear.left + (clear.right - clear.left) / 2; + const centerY = clear.top + (clear.bottom - clear.top) / 2; const distanceFromCenter = Math.hypot( - (point.x - centerX) / (mapBox.width / 2), - (point.y - centerY) / (mapBox.height / 2) + (point.x - centerX) / Math.max(1, (clear.right - clear.left) / 2), + (point.y - centerY) / Math.max(1, (clear.bottom - clear.top) / 2) ); return { h3: feature.properties.postcode, @@ -281,10 +314,10 @@ export class DashboardRecorder { ); const clearOfChrome = onScreen.filter( (target) => - target.x >= mapBox.x + 80 && - target.x <= mapBox.x + mapBox.width - 130 && - target.y >= mapBox.y + 105 && - target.y <= mapBox.y + mapBox.height - 115 + target.x >= clear.left && + target.x <= clear.right && + target.y >= clear.top && + target.y <= clear.bottom ); const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort( diff --git a/video/src/dom.ts b/video/src/dom.ts index 6758628..c0e94ed 100644 --- a/video/src/dom.ts +++ b/video/src/dom.ts @@ -1,4 +1,5 @@ import type { Page } from 'playwright'; +import type { AdScene } from './script.js'; /** * Inject a visible cursor that mirrors the real mouse position. The browser's @@ -153,6 +154,208 @@ export async function installCursor(page: Page): Promise { letter-spacing: 0; color: #99f6e4; } + + .__ad-scene { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 2147483639; + opacity: 0; + transition: opacity 260ms ease-out; + color: #f8fafc; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + letter-spacing: 0; + } + .__ad-scene.visible { opacity: 1; } + .__ad-scene.accent-teal { --ad-accent: #2dd4bf; --ad-accent-soft: rgba(45, 212, 191, 0.22); } + .__ad-scene.accent-sky { --ad-accent: #38bdf8; --ad-accent-soft: rgba(56, 189, 248, 0.22); } + .__ad-scene.accent-amber { --ad-accent: #f59e0b; --ad-accent-soft: rgba(245, 158, 11, 0.24); } + .__ad-scene.accent-rose { --ad-accent: #fb7185; --ad-accent-soft: rgba(251, 113, 133, 0.22); } + .__ad-scene.accent-lime { --ad-accent: #a3e635; --ad-accent-soft: rgba(163, 230, 53, 0.18); } + .__ad-scene.accent-violet { --ad-accent: #a78bfa; --ad-accent-soft: rgba(167, 139, 250, 0.22); } + .__ad-scrim { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(2, 6, 23, 0.86) 0%, rgba(2, 6, 23, 0.46) 44%, rgba(2, 6, 23, 0.88) 100%), + linear-gradient(135deg, var(--ad-accent-soft), transparent 38%, rgba(15, 23, 42, 0.38)); + backdrop-filter: blur(2px) saturate(0.92); + -webkit-backdrop-filter: blur(2px) saturate(0.92); + } + .__ad-frame { + position: absolute; + top: 118px; + left: 58px; + right: 58px; + bottom: 330px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 24px; + } + .__ad-kicker { + align-self: flex-start; + padding: 10px 14px; + border-radius: 8px; + color: #020617; + background: var(--ad-accent); + font: 800 28px/1 ui-sans-serif, system-ui, sans-serif; + text-transform: uppercase; + } + .__ad-comment { + max-width: 820px; + padding: 22px 24px; + border-radius: 8px; + background: rgba(248, 250, 252, 0.94); + color: #0f172a; + box-shadow: 0 20px 70px rgba(0, 0, 0, 0.34); + font: 700 34px/1.18 ui-sans-serif, system-ui, sans-serif; + } + .__ad-title { + margin: 0; + max-width: 940px; + color: #fff; + font: 850 82px/1.02 ui-sans-serif, system-ui, sans-serif; + text-wrap: balance; + } + .__ad-body { + max-width: 890px; + margin: 0; + color: #dbeafe; + font: 560 38px/1.24 ui-sans-serif, system-ui, sans-serif; + text-wrap: balance; + } + .__ad-split { + display: grid; + grid-template-columns: 1fr; + gap: 18px; + margin-top: 4px; + } + .__ad-panel { + border-radius: 8px; + padding: 24px 26px; + background: rgba(15, 23, 42, 0.78); + border: 1px solid rgba(226, 232, 240, 0.18); + box-shadow: 0 18px 54px rgba(0, 0, 0, 0.26); + } + .__ad-panel-title { + font: 800 42px/1.08 ui-sans-serif, system-ui, sans-serif; + color: #fff; + } + .__ad-panel-subtitle { + margin-top: 10px; + font: 570 28px/1.22 ui-sans-serif, system-ui, sans-serif; + color: #cbd5e1; + } + .__ad-panel-meta { + margin-top: 16px; + display: inline-flex; + padding: 8px 12px; + border-radius: 8px; + background: rgba(148, 163, 184, 0.18); + color: #e2e8f0; + font: 750 24px/1 ui-sans-serif, system-ui, sans-serif; + } + .__ad-items { + display: grid; + gap: 14px; + margin-top: 4px; + } + .__ad-item { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 16px; + min-height: 74px; + padding: 17px 20px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.78); + border: 1px solid rgba(226, 232, 240, 0.16); + box-shadow: 0 14px 42px rgba(0, 0, 0, 0.22); + } + .__ad-rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 8px; + color: #020617; + background: var(--ad-accent); + font: 850 24px/1 ui-sans-serif, system-ui, sans-serif; + } + .__ad-item-label { + color: #f8fafc; + font: 720 31px/1.13 ui-sans-serif, system-ui, sans-serif; + } + .__ad-item-value { + color: #bfdbfe; + font: 760 28px/1.1 ui-sans-serif, system-ui, sans-serif; + text-align: right; + } + .__ad-item.good { border-color: rgba(74, 222, 128, 0.45); } + .__ad-item.bad { border-color: rgba(251, 113, 133, 0.48); } + .__ad-item.warn { border-color: rgba(251, 191, 36, 0.48); } + .__ad-item.good .__ad-item-value { color: #86efac; } + .__ad-item.bad .__ad-item-value { color: #fda4af; } + .__ad-item.warn .__ad-item-value { color: #fde68a; } + .__ad-panel.good { border-color: rgba(74, 222, 128, 0.48); } + .__ad-panel.bad { border-color: rgba(251, 113, 133, 0.48); } + .__ad-panel.warn { border-color: rgba(251, 191, 36, 0.48); } + .__ad-footer { + margin-top: 6px; + color: #e0f2fe; + font: 760 31px/1.18 ui-sans-serif, system-ui, sans-serif; + } + .__ad-progress { + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(226, 232, 240, 0.22); + } + .__ad-progress-fill { + height: 100%; + width: 0%; + background: var(--ad-accent); + box-shadow: 0 0 28px var(--ad-accent); + } + .__ad-scene.mode-title .__ad-frame { + justify-content: center; + } + .__ad-scene.mode-title .__ad-title { + font-size: 94px; + } + .__ad-scene.mode-comment .__ad-comment { + margin-bottom: 12px; + } + .__ad-scene.mode-tabs .__ad-items { + grid-template-columns: 1fr 1fr; + } + .__ad-scene.mode-tabs .__ad-item { + grid-template-columns: auto 1fr; + min-height: 86px; + } + .__ad-scene.mode-tabs .__ad-item-value { + display: none; + } + .__ad-scene.mode-scanner .__ad-frame::after, + .__ad-scene.mode-polygraph .__ad-frame::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 48%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--ad-accent), transparent); + box-shadow: 0 0 28px var(--ad-accent); + animation: __ad-scan 1.8s ease-in-out infinite; + } + @keyframes __ad-scan { + 0% { transform: translateY(-180px); opacity: 0; } + 18% { opacity: 1; } + 82% { opacity: 1; } + 100% { transform: translateY(180px); opacity: 0; } + } `, }); @@ -296,6 +499,81 @@ export async function visualClick( ); } +export async function showAdScene(page: Page, scene: AdScene): Promise { + await page.evaluate((s) => { + const mode = s.mode ?? 'stack'; + const accent = s.accent ?? 'teal'; + + const make = (tag: string, className: string, text?: string): HTMLElement => { + const el = document.createElement(tag); + el.className = className; + if (text) el.textContent = text; + return el; + }; + + const root = + document.getElementById('__ad-scene') ?? document.createElement('div'); + root.id = '__ad-scene'; + root.className = `__ad-scene mode-${mode} accent-${accent}`; + root.replaceChildren(); + + const scrim = make('div', '__ad-scrim'); + const frame = make('div', '__ad-frame'); + + if (s.comment) frame.appendChild(make('div', '__ad-comment', s.comment)); + if (s.kicker) frame.appendChild(make('div', '__ad-kicker', s.kicker)); + frame.appendChild(make('h1', '__ad-title', s.title)); + if (s.body) frame.appendChild(make('p', '__ad-body', s.body)); + + if (s.left || s.right) { + const split = make('div', '__ad-split'); + for (const panel of [s.left, s.right]) { + if (!panel) continue; + const panelEl = make('div', `__ad-panel ${panel.tone ?? 'neutral'}`); + panelEl.appendChild(make('div', '__ad-panel-title', panel.title)); + if (panel.subtitle) { + panelEl.appendChild(make('div', '__ad-panel-subtitle', panel.subtitle)); + } + if (panel.meta) panelEl.appendChild(make('div', '__ad-panel-meta', panel.meta)); + split.appendChild(panelEl); + } + frame.appendChild(split); + } + + if (s.items?.length) { + const list = make('div', '__ad-items'); + s.items.forEach((item, index) => { + const row = make('div', `__ad-item ${item.tone ?? 'neutral'}`); + row.appendChild(make('span', '__ad-rank', String(index + 1))); + row.appendChild(make('span', '__ad-item-label', item.label)); + row.appendChild(make('span', '__ad-item-value', item.value ?? '')); + list.appendChild(row); + }); + frame.appendChild(list); + } + + if (typeof s.progress === 'number') { + const progress = make('div', '__ad-progress'); + const fill = make('div', '__ad-progress-fill'); + fill.style.width = `${Math.round(Math.max(0, Math.min(1, s.progress)) * 100)}%`; + progress.appendChild(fill); + frame.appendChild(progress); + } + + if (s.footer) frame.appendChild(make('div', '__ad-footer', s.footer)); + + root.append(scrim, frame); + if (!root.parentElement) document.body.appendChild(root); + requestAnimationFrame(() => root.classList.add('visible')); + }, scene); +} + +export async function hideAdScene(page: Page): Promise { + await page.evaluate(() => { + document.getElementById('__ad-scene')?.classList.remove('visible'); + }); +} + export async function showOutro( page: Page, brand: string, diff --git a/video/src/record.ts b/video/src/record.ts index fdec500..49ea163 100644 --- a/video/src/record.ts +++ b/video/src/record.ts @@ -13,6 +13,19 @@ async function recordOne(storyboard: Storyboard): Promise { const dir = join(OUTPUT_DIR, storyboard.name); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const trimmedPath = join(dir, 'recording.webm'); + const narrationPath = join(dir, 'narration.json'); + if ( + process.env.RESUME_RECORDINGS === '1' && + existsSync(trimmedPath) && + statSync(trimmedPath).size > 0 && + existsSync(narrationPath) && + statSync(narrationPath).size > 0 + ) { + console.log(`\n=== [${storyboard.name}] recording exists, skipping ===`); + return; + } + console.log(`\n=== [${storyboard.name}] recording ===`); const { browser, context } = await launchRecordingBrowser(storyboard, { @@ -45,15 +58,15 @@ async function recordOne(storyboard: Storyboard): Promise { await page.close(); const rawPath = join(dir, 'recording.raw.webm'); - if (recordedVideo) await recordedVideo.saveAs(rawPath); await context.close(); + if (recordedVideo) await recordedVideo.saveAs(rawPath); await browser.close(); if (!recordedVideo || !statSync(rawPath).size) { throw new Error(`[${storyboard.name}] no recorded webm found`); } - trimRecording(rawPath, join(dir, 'recording.webm'), storyboard, { + trimRecording(rawPath, trimmedPath, storyboard, { recordStartMs, ...timeline, }); @@ -61,7 +74,7 @@ async function recordOne(storyboard: Storyboard): Promise { const totalDurationMs = timeline.sceneEndMs - timeline.sceneStartMs + LEAD_IN_S * 1000; const cues = narrationLog.flush( - join(dir, 'narration.json'), + narrationPath, totalDurationMs ); console.log( diff --git a/video/src/runner.ts b/video/src/runner.ts index b98d25f..473324f 100644 --- a/video/src/runner.ts +++ b/video/src/runner.ts @@ -4,8 +4,10 @@ import type { Page } from 'playwright'; import { LEAD_IN_S, OUTPUT_DIR } from './config.js'; import { clearVignette, + hideAdScene, hideCaption, setCursorScale, + showAdScene, showCaption, showOutro, zoomReset, @@ -195,8 +197,9 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { const point = await resolveTarget(ctx, step.target); await ctx.page.mouse.move(point.x, point.y); const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps)); + const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA; for (let i = 0; i < step.steps; i++) { - await ctx.page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA); + await ctx.page.mouse.wheel(0, delta); if (perStepMs > 0) await sleep(perStepMs); } return; @@ -219,6 +222,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { case 'showOutro': await showOutro(ctx.page, step.brand, step.tagline, step.url); return; + case 'showAdScene': + await showAdScene(ctx.page, step.scene); + return; + case 'hideAdScene': + await hideAdScene(ctx.page); + return; } } @@ -232,6 +241,13 @@ async function resolveTarget( if (targets.length === 0) throw new Error('No visible hexagon to target'); return { x: targets[0].x, y: targets[0].y }; } + if (target.kind === 'viewportFraction') { + const viewport = ctx.page.viewportSize() ?? { width: 1920, height: 1080 }; + return { + x: Math.round(Math.min(1, Math.max(0, target.xFrac)) * viewport.width), + y: Math.round(Math.min(1, Math.max(0, target.yFrac)) * viewport.height), + }; + } const box = await ctx.page.locator(target.selector).boundingBox(); 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 61682f2..84a3534 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -22,7 +22,47 @@ export interface ScriptCtx { cursor: { x: number; y: number }; } -/** A point on screen, either absolute pixel coords or the centre of an element. */ +export type AdSceneAccent = 'teal' | 'sky' | 'amber' | 'rose' | 'lime' | 'violet'; +export type AdSceneMode = + | 'title' + | 'stack' + | 'split' + | 'rank' + | 'comment' + | 'scanner' + | 'tabs' + | 'receipt' + | 'polygraph' + | 'match'; + +export interface AdSceneItem { + label: string; + value?: string; + tone?: 'good' | 'bad' | 'warn' | 'neutral'; +} + +export interface AdScenePanel { + title: string; + subtitle?: string; + meta?: string; + tone?: 'good' | 'bad' | 'warn' | 'neutral'; +} + +export interface AdScene { + mode?: AdSceneMode; + accent?: AdSceneAccent; + kicker?: string; + title: string; + body?: string; + comment?: string; + footer?: string; + progress?: number; + left?: AdScenePanel; + right?: AdScenePanel; + items?: AdSceneItem[]; +} + +/** A point on screen, resolved at runtime to viewport pixels. */ export type Target = | { kind: 'point'; x: number; y: number } | { kind: 'element'; selector: string } @@ -32,11 +72,24 @@ export type Target = * level — use this when the click MUST land on a polygon and a fixed pixel * coordinate would risk landing on a road or river at deep zoom. */ - | { kind: 'hexagon' }; + | { kind: 'hexagon' } + /** + * Resolved at runtime to (xFrac·viewport.width, yFrac·viewport.height). Use + * this when an activity needs a stable visual location that scales with the + * aspect (e.g. "centre of the map area" on both 16x9 desktop and 9x16 + * mobile, where the visible map is only the top portion above the bottom + * sheet). Both fractions are clamped to [0, 1]. + */ + | { kind: 'viewportFraction'; xFrac: number; yFrac: number }; export const at = (x: number, y: number): Target => ({ kind: 'point', x, y }); export const el = (selector: string): Target => ({ kind: 'element', selector }); export const hex = (): Target => ({ kind: 'hexagon' }); +export const vfrac = (xFrac: number, yFrac: number): Target => ({ + kind: 'viewportFraction', + xFrac, + yFrac, +}); /** * Activities are the runner's atomic operations. Each one has a fixed @@ -59,9 +112,17 @@ export type Activity = | { kind: 'cursorScale'; scale: number; durationMs: number } /** * Wheel-zoom the underlying map at `target`. `steps` controls intensity - * (each step is one ~120px wheel notch). + * (each step is one ~120px wheel notch). `direction` picks zoom in (the + * default) or zoom out, so the same activity can both dive into a + * postcode and later pull back to the filtered overview. */ - | { kind: 'mapZoom'; target: Target; steps: number; durationMs: number } + | { + kind: 'mapZoom'; + target: Target; + steps: number; + durationMs: number; + direction?: 'in' | 'out'; + } /** Drag the right thumb of a Radix slider to a fraction in [0,1]. */ | { kind: 'dragSlider'; @@ -74,6 +135,10 @@ export type Activity = | { kind: 'submitForm'; formSelector: string; durationMs: number } /** Reveal the closing brand card. */ | { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number } + /** Reveal a full-screen ad-style overlay over the live map. */ + | { kind: 'showAdScene'; scene: AdScene; durationMs: number } + /** Fade the ad overlay away. */ + | { kind: 'hideAdScene'; durationMs: number } /** Fade away the opening vignette. */ | { kind: 'clearVignette'; durationMs: number }; @@ -100,10 +165,19 @@ export interface Cue { /** Recorder + encoder knobs. Set per storyboard so vertical/horizontal cuts * can coexist without env-var juggling. */ export interface VideoConfig { - /** "16x9" → 1920x1080, "9x16" → 1080x1920. */ + /** "16x9" → 1920x1080, "9x16" → 1080x1920 by default. */ aspect: '16x9' | '9x16'; /** Browser deviceScaleFactor. >1 supersamples for sharper text. */ captureScale: number; + /** + * Optional CSS viewport override (in pixels). Lets a single storyboard + * record at a narrower CSS viewport than the aspect's default — e.g. + * the recording-*-mobile cuts use 540x960 so Tailwind's `md:` + * breakpoint (≥768px) doesn't match and every component picks its + * mobile typography. Pair with `captureScale: 2` to keep text sharp. + * If unset, the viewport comes from `viewportFor(aspect)`. + */ + viewport?: { width: number; height: number }; /** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */ webmBitrate: string; /** Final fps after the trim/resample pass. */ @@ -191,9 +265,28 @@ export interface Storyboard { post?: Activity[]; } -/** Convenience: derive the viewport from aspect. */ +/** + * Frontend viewport in CSS pixels. Defaults to the aspect's native size + * (1920x1080 for 16x9, 1080x1920 for 9x16). A storyboard can opt into a + * narrower CSS viewport via `video.viewport` — e.g. recording-*-mobile + * uses 540x960 so the frontend's Tailwind `md:` breakpoint doesn't match + * and every component picks mobile typography/spacing. Pair the override + * with `captureScale: 2` to keep text sharp at the smaller resolution. + */ export function viewportFor(video: VideoConfig): { width: number; height: number } { + if (video.viewport) return video.viewport; return video.aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 }; } + +/** + * Recorded video resolution. Equal to the CSS viewport because + * Playwright's recordVideo writes frames at CSS pixel size regardless + * of `deviceScaleFactor`. Kept as a separate function so future + * supersample + post-encode flows (e.g. ffmpeg lanczos upscale) can + * plug in here without touching verify.ts. + */ +export function recordedSizeFor(video: VideoConfig): { width: number; height: number } { + return viewportFor(video); +} diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index cf9f0c8..ffc0fd9 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -1,4 +1,15 @@ -import { el, type Storyboard } from './script.js'; +import { + el, + hex, + vfrac, + type Activity, + type AdScene, + type Storyboard, + type TravelTimeFilter, + type VideoConfig, +} from './script.js'; + +type FormFactor = 'desktop' | 'mobile'; /** * The list of demo videos to render, in order. @@ -24,13 +35,35 @@ import { el, type Storyboard } from './script.js'; * before the next cue's gap). */ -const AI_ZOOM_SCALE = 2.4; +// 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.4; 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; +// Where on the map the cue-4 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). +const MAP_FOCUS_DESKTOP = vfrac(1140 / 1920, 605 / 1080); +const MAP_FOCUS_MOBILE = vfrac(0.5, 0.3); + +// Mobile mapZoom intensity. 6 wheel-steps from the initial zoom (12) +// lands around zoom 14.5 — postcode polygons clearly visible, individual +// streets named, hex aggregation broken open. The previous 18-step +// drill ended past zoom 20 (street-level vector tiles only), so the +// click landed on featureless terrain. +const MOBILE_MAP_ZOOM_STEPS = 6; +const MOBILE_MAP_ZOOM_MS = 1400; + type RecordingLocale = 'en' | 'de' | 'zh' | 'hi'; interface RecordingLocalization { @@ -49,6 +82,7 @@ interface RecordingLocalization { }; cues: { describe: string; + prompt: string; dashboard: string; filters: string; details: string; @@ -77,12 +111,12 @@ const RECORDING_LOCALIZATIONS: Record = url: BRAND_URL, }, cues: { - describe: 'Stop starting with places you already know.', - dashboard: 'Tell the map what has to be true.', - filters: 'Adjust the filters to narrow down to the best candidates', - details: 'Open a postcode before you book a viewing. Check the evidence.', - shortlist: - 'Now you can take your shortlist and start looking for your next home in your perfect postcode.', + describe: "Don't pick a home by scrolling listings.", + prompt: 'Just 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.', + details: 'Open any postcode. The evidence: sold prices, schools, crime, noise.', + shortlist: 'Take your shortlist to the listings. Now you know where to search.', }, }, de: { @@ -104,13 +138,15 @@ const RECORDING_LOCALIZATIONS: Record = url: BRAND_URL, }, cues: { - describe: 'Beginnen Sie nicht mit Orten, die Sie schon kennen.', - dashboard: 'Sagen Sie der Karte, was wirklich stimmen muss.', - filters: 'Falsche Postleitzahlen verschwinden. Verschärfen Sie die Pendelzeit.', + describe: 'Wählen Sie kein Zuhause durch endloses Scrollen.', + prompt: + 'Beschreiben Sie einfach, was Ihnen 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.', details: - 'Öffnen Sie eine Postleitzahl, bevor Sie eine Besichtigung buchen. Prüfen Sie die Fakten.', + 'Öffnen Sie eine Postleitzahl. Preise, Schulen, Kriminalität, Lärm — die Beweise.', shortlist: - 'Nehmen Sie diese Auswahl zu den Inseraten und suchen Sie in den richtigen Gegenden.', + 'Mit dieser Auswahl zu den Inseraten — Sie wissen jetzt, wo Sie suchen sollen.', }, }, zh: { @@ -130,11 +166,12 @@ const RECORDING_LOCALIZATIONS: Record = url: BRAND_URL, }, cues: { - describe: '不要从已经熟悉的地方开始。', - dashboard: '先告诉地图,哪些条件必须满足。', - filters: '不合适的邮编会消失。再收紧通勤条件。', - details: '看房前先打开一个邮编,查看证据。', - shortlist: '带着这份清单去房源网站,在更合适的地方搜索。', + describe: '别再靠刷房源挑家了。', + prompt: '用日常话告诉地图你想要的家——预算、通勤、学校,什么都行。', + dashboard: '地图点亮每一个符合条件的英格兰邮编。', + filters: '动一个滑块,地图立刻给答案。', + details: '打开任意邮编:成交价、学校、犯罪率、噪音——一目了然。', + shortlist: '带着这份清单去房源网站。现在你知道该在哪儿找了。', }, }, hi: { @@ -155,35 +192,79 @@ const RECORDING_LOCALIZATIONS: Record = url: BRAND_URL, }, cues: { - describe: 'Stop starting with places you already know.', - dashboard: 'Tell the map what has to be true.', - filters: 'Wrong postcodes disappear. Tighten the commute.', - details: 'Open a postcode before you book a viewing. Check the evidence.', - shortlist: 'Take that shortlist to the listings and search in the right places.', + describe: "Don't pick a home by scrolling listings.", + prompt: 'Just 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.', + details: 'Open any postcode. The evidence: sold prices, schools, crime, noise.', + shortlist: 'Take your shortlist to the listings. Now you know where to search.', }, }, }; -function createCues(locale: RecordingLocale): Storyboard['cues'] { +function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard['cues'] { const copy = RECORDING_LOCALIZATIONS[locale]; + const isMobile = formFactor === 'mobile'; + const mapFocus = isMobile ? MAP_FOCUS_MOBILE : MAP_FOCUS_DESKTOP; + const mapZoomSteps = isMobile ? MOBILE_MAP_ZOOM_STEPS : 18; + const mapZoomMs = isMobile ? MOBILE_MAP_ZOOM_MS : 1500; + // Mobile clicks land on a real visible polygon via `hex()` — the deep + // zoom is shallow enough that the map's most recent /api/postcodes + // response gives us a real candidate. Desktop clicks the same pixel + // we zoomed into because the right-pane reveal looks tidier when the + // cursor stays put. + const clickTarget = isMobile ? hex() : mapFocus; + + // Cue 5 (shortlist) on mobile: the Export button lives inside the + // hidden hamburger menu, not in the header — opening it cleanly would + // need a localised aria-label lookup. Instead we pull the map back + // out to the filtered overview so the cut ends on a satisfying wide + // shot of the matching postcodes rather than the post-click zoom. + const shortlistActivities: Storyboard['cues'][number]['during'] = + formFactor === 'desktop' + ? [ + { kind: 'zoomReset', durationMs: 900 }, + { + kind: 'click', + target: el(`button[title="${copy.exportButtonTitle}"]`), + durationMs: 800, + }, + ] + : [ + // Reverse the cue-4 zoom-in exactly so we land back on the + // initial filtered dashboard view (hexagons visible). + { + kind: 'mapZoom', + target: mapFocus, + steps: MOBILE_MAP_ZOOM_STEPS, + durationMs: MOBILE_MAP_ZOOM_MS, + direction: 'out', + }, + ]; return [ { text: copy.cues.describe, gapBeforeMs: 0, - tail: [ + tail: [{ kind: 'wait', durationMs: 250 }], + }, + { + text: copy.cues.prompt, + gapBeforeMs: 0, + during: [ { kind: 'type', selector: '[data-tutorial="ai-filters"] textarea', text: copy.promptText, durationMs: 3000, }, - { kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 }, + { kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1200 }, ], + tail: [{ kind: 'wait', durationMs: 500 }], }, { text: copy.cues.dashboard, - gapBeforeMs: 400, + gapBeforeMs: 300, during: [{ kind: 'zoomReset', durationMs: 1400 }], tail: [{ kind: 'wait', durationMs: 500 }], }, @@ -210,9 +291,9 @@ function createCues(locale: RecordingLocale): Storyboard['cues'] { { kind: 'cursorScale', scale: 1.4, durationMs: 200 }, { kind: 'mapZoom', - target: { kind: 'point', x: 1140, y: 605 }, - steps: 18, - durationMs: 1500, + target: mapFocus, + steps: mapZoomSteps, + durationMs: mapZoomMs, }, ], tail: [ @@ -222,7 +303,7 @@ function createCues(locale: RecordingLocale): Storyboard['cues'] { { kind: 'wait', durationMs: 500 }, { kind: 'click', - target: { kind: 'point', x: 1140, y: 605 }, + target: clickTarget, durationMs: 700, }, { kind: 'cursorScale', scale: 1, durationMs: 280 }, @@ -234,14 +315,7 @@ function createCues(locale: RecordingLocale): Storyboard['cues'] { { text: copy.cues.shortlist, gapBeforeMs: 500, - during: [ - { kind: 'zoomReset', durationMs: 900 }, - { - kind: 'click', - target: el(`button[title="${copy.exportButtonTitle}"]`), - durationMs: 800, - }, - ], + during: shortlistActivities, tail: [{ kind: 'wait', durationMs: 800 }], }, @@ -262,39 +336,88 @@ function createCues(locale: RecordingLocale): Storyboard['cues'] { ]; } -const DEFAULT_PRE: Storyboard['pre'] = [ - { kind: 'clearVignette', durationMs: 0 }, - { kind: 'wait', durationMs: 200 }, - { - kind: 'zoomTo', - target: el('[data-tutorial="ai-filters"]'), - scale: AI_ZOOM_SCALE, - durationMs: 1300, - }, - { kind: 'wait', durationMs: 140 }, -]; +function buildPre(formFactor: FormFactor): Storyboard['pre'] { + if (formFactor === 'mobile') { + // Mobile skips the wrapper-zoom into the AI card. On a 540-wide + // viewport the bottom sheet already occupies ~44% of the screen + // and the AI card sits at the top of it — leaning further in would + // overflow the card width and crop the placeholder. We just clear + // the vignette and let the typing draw the eye. + return [ + { kind: 'clearVignette', durationMs: 0 }, + { kind: 'wait', durationMs: 400 }, + ]; + } + return [ + { kind: 'clearVignette', durationMs: 0 }, + { kind: 'wait', durationMs: 200 }, + { + kind: 'zoomTo', + target: el('[data-tutorial="ai-filters"]'), + scale: AI_ZOOM_SCALE_DESKTOP, + durationMs: 1300, + }, + { kind: 'wait', durationMs: 140 }, + ]; +} -function createRecordingStoryboard(locale: RecordingLocale): Storyboard { - const copy = RECORDING_LOCALIZATIONS[locale]; - - return { - name: copy.name, - locale, - video: { - aspect: '16x9', - 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. - webmBitrate: '8M', +function buildVideoConfig(formFactor: FormFactor): VideoConfig { + if (formFactor === 'mobile') { + return { + aspect: '9x16', + // 540x960 CSS viewport (narrower than Tailwind's 768px `md:` + // breakpoint) so the frontend picks mobile typography/spacing. + // DPR 2 makes the browser render internally at 1080x1920 device + // pixels for sharp antialiasing; the screencast still writes the + // mp4 at 540x960. Result: a sharp phone-sized 9:16 cut that + // genuinely looks mobile-styled, not desktop in portrait. + viewport: { width: 540, height: 960 }, + captureScale: 2, + // 540x960 needs less bitrate than 1080p — 4M is comfortable. + webmBitrate: '4M', outputFps: 50, minDurationS: 10, - maxDurationS: 60, - // 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, - }, + maxDurationS: 75, + // Click + drawer-open moment — landing on the right-pane reveal. + posterTimeS: 12, + }; + } + return { + aspect: '16x9', + 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. + 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, + }; +} + +function storyboardName(copy: RecordingLocalization, formFactor: FormFactor): string { + return formFactor === 'mobile' ? `${copy.name}-mobile` : copy.name; +} + +function createRecordingStoryboard( + locale: RecordingLocale, + formFactor: FormFactor +): Storyboard { + const copy = RECORDING_LOCALIZATIONS[locale]; + // Mobile bumps the initial zoom from 11.5 → 12 so the narrower 540-wide + // viewport still shows a Manchester-metro slice densely populated with + // hexagons (otherwise the visible map gets dominated by Pennine moors + // on the east edge with no matches). + const initialZoom = formFactor === 'mobile' ? 12 : 11.5; + + return { + name: storyboardName(copy, formFactor), + locale: formFactor === 'mobile' ? `${locale}-mobile` : locale, + video: buildVideoConfig(formFactor), voice: { instruct: copy.voiceInstruct, language: copy.ttsLanguage, @@ -306,8 +429,10 @@ function createRecordingStoryboard(locale: RecordingLocale): Storyboard { content: { promptText: copy.promptText, appLanguage: copy.appLanguage, - aiZoomScale: AI_ZOOM_SCALE, - initialMapView: { lat: 53.4795, lon: -2.2451, zoom: 11.5 }, + // 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: 53.4795, lon: -2.2451, zoom: initialZoom }, // Filters returned by the AI stub. Keys MUST match real feature names // from /api/features (verified against the running server's schema). stubbedFilters: { @@ -332,15 +457,837 @@ function createRecordingStoryboard(locale: RecordingLocale): Storyboard { travelTimeDragToMin: TT_DRAG_TO_MIN, brand: copy.brand, }, - pre: DEFAULT_PRE, - cues: createCues(locale), + pre: buildPre(formFactor), + cues: createCues(locale, formFactor), }; } -export const storyboards: Storyboard[] = (['en', 'de', 'zh', 'hi'] as const).map((locale) => - createRecordingStoryboard(locale) +const RECORDING_LOCALES: readonly RecordingLocale[] = ['en', 'de', 'zh', 'hi']; +const RECORDING_FORM_FACTORS: readonly FormFactor[] = ['desktop', 'mobile']; + +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'; + +interface AdCueConfig { + text: string; + scene: AdScene; + gapBeforeMs?: number; + tail?: Activity[]; +} + +interface AdStoryboardConfig { + name: string; + city: CityKey; + promptText?: string; + filters?: Record; + travelTimeFilters?: TravelTimeFilter[]; + posterTimeS?: number; + cues: AdCueConfig[]; +} + +const AD_VIDEO: VideoConfig = { + aspect: '9x16', + captureScale: 1, + webmBitrate: '8M', + outputFps: 50, + minDurationS: 8, + maxDurationS: 35, + posterTimeS: 4, +}; + +const AD_BRAND = { + name: 'Perfect Postcode', + tagline: 'Check the postcode before the viewing.', + url: BRAND_URL, +}; + +const AD_VOICE = { + instruct: + 'Modern British creator-style narrator. Crisp, curious, understated, quick pace, ' + + 'no exaggerated sales voice.', + language: 'English', + referenceText: + 'This is a short social video for people choosing where to live in England.', + temperature: 0.58, + topP: 0.9, + seed: 87, +}; + +const CITY_VIEWS: Record = { + manchester: { lat: 53.4795, lon: -2.2451, zoom: 11.3 }, + birmingham: { lat: 52.4862, lon: -1.8904, zoom: 11.1 }, + bristol: { lat: 51.4545, lon: -2.5879, zoom: 11.4 }, + london: { lat: 51.5072, lon: -0.1276, zoom: 10.6 }, + leeds: { lat: 53.8008, lon: -1.5491, zoom: 11.2 }, + liverpool: { lat: 53.4084, lon: -2.9916, zoom: 11.2 }, + sheffield: { lat: 53.3811, lon: -1.4701, zoom: 11.3 }, +}; + +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], +}; + +const adDrift = (direction: 'in' | 'out' = 'in'): Activity[] => [ + { + kind: 'mapZoom', + target: vfrac(0.5, 0.32), + steps: 2, + durationMs: 460, + direction, + }, +]; + +const linger = (durationMs = 360): Activity[] => [{ kind: 'wait', durationMs }]; + +const adCue = ( + text: string, + scene: AdScene, + tail: Activity[] = linger() +): AdCueConfig => ({ text, scene, tail }); + +function createAdStoryboard(ad: AdStoryboardConfig): Storyboard { + return { + name: ad.name, + locale: 'en', + video: { ...AD_VIDEO, posterTimeS: ad.posterTimeS ?? AD_VIDEO.posterTimeS }, + voice: AD_VOICE, + content: { + promptText: + ad.promptText ?? + 'Flat under £350k, good commute, good schools, lower crime, quieter streets', + appLanguage: 'en', + aiZoomScale: 1, + initialMapView: CITY_VIEWS[ad.city], + stubbedFilters: ad.filters ?? AD_DEFAULT_FILTERS, + stubbedTravelTimeFilters: ad.travelTimeFilters ?? [], + travelTimeCardSelector: TT_CARD_SELECTOR, + travelTimeSliderMax: TT_SLIDER_MAX, + travelTimeDragFromMin: TT_DRAG_FROM_MIN, + travelTimeDragToMin: TT_DRAG_TO_MIN, + brand: AD_BRAND, + }, + pre: [ + { kind: 'clearVignette', durationMs: 0 }, + { kind: 'cursorScale', scale: 0, durationMs: 0 }, + { kind: 'wait', durationMs: 160 }, + ], + cues: [ + ...ad.cues.map((cue, index) => ({ + text: cue.text, + gapBeforeMs: cue.gapBeforeMs ?? (index === 0 ? 0 : 220), + during: [{ kind: 'showAdScene' as const, scene: cue.scene, durationMs: 0 }], + tail: cue.tail ?? linger(), + })), + { + text: 'Perfect Postcode. Check the postcode before the viewing.', + gapBeforeMs: 240, + during: [ + { + kind: 'showOutro', + brand: AD_BRAND.name, + tagline: AD_BRAND.tagline, + url: AD_BRAND.url, + durationMs: 0, + }, + ], + tail: [{ kind: 'wait', durationMs: 1050 }], + }, + ], + }; +} + +const AD_CONFIGS: AdStoryboardConfig[] = [ + { + name: 'ad-01-normal-until-data', + city: 'manchester', + cues: [ + adCue( + 'This postcode looks normal, until you check the data.', + { + mode: 'scanner', + accent: 'teal', + kicker: 'Postcode check', + title: 'Looks normal.', + body: 'The numbers say otherwise.', + items: [ + { label: 'Sold prices', value: 'shifted', tone: 'warn' }, + { label: 'Road noise', value: 'high', tone: 'bad' }, + { label: 'Schools nearby', value: 'thin', tone: 'warn' }, + ], + }, + adDrift() + ), + adCue('Photos cannot show the area around the front door.', { + mode: 'receipt', + accent: 'sky', + title: 'What the listing skips', + items: [ + { label: 'Commute reality', value: 'hidden', tone: 'warn' }, + { label: 'Local crime', value: 'not pictured', tone: 'bad' }, + { label: 'Noise exposure', value: 'not staged', tone: 'bad' }, + ], + }), + adCue('Check the postcode before the viewing.', { + mode: 'title', + accent: 'lime', + title: 'Still booking it?', + body: 'One click tells you what the photos cannot.', + footer: 'Tap to run the check', + }), + ], + }, + { + name: 'ad-02-guess-300k', + city: 'birmingham', + cues: [ + adCue('Guess which area fits a three hundred grand budget.', { + mode: 'split', + accent: 'amber', + kicker: 'Quick test', + title: 'Which one is under £300k?', + left: { + title: 'Area A', + subtitle: 'Fast station access, lower noise, better school reach.', + meta: 'Looks expensive', + tone: 'good', + }, + right: { + title: 'Area B', + subtitle: 'Longer commute, noisier roads, weaker local mix.', + meta: 'Looks cheaper', + tone: 'warn', + }, + }), + adCue('The answer is rarely the one your gut picks.', { + mode: 'title', + accent: 'rose', + title: 'The map flips the guess.', + body: 'Price alone is a bad shortcut.', + }), + adCue('Find the surprising postcodes before everyone else does.', { + mode: 'rank', + accent: 'teal', + title: 'Better places hide in the data', + items: [ + { label: 'Budget fit', value: 'yes', tone: 'good' }, + { label: 'Commute fit', value: 'yes', tone: 'good' }, + { label: 'Area risk', value: 'check first', tone: 'warn' }, + ], + }), + ], + }, + { + name: 'ad-03-perfect-house-postcode', + city: 'bristol', + cues: [ + adCue('The house was perfect. The postcode was not.', { + mode: 'title', + accent: 'rose', + kicker: 'Viewing trap', + title: 'Perfect house.', + body: 'Wrong postcode.', + }), + adCue('The red flags were never in the photos.', { + mode: 'receipt', + accent: 'amber', + title: 'After the viewing', + items: [ + { label: 'Commute', value: '1h 12m', tone: 'bad' }, + { label: 'Road noise', value: 'elevated', tone: 'bad' }, + { label: 'Nearby schools', value: 'weak fit', tone: 'warn' }, + ], + }), + adCue('Fall in love with the area first.', { + mode: 'stack', + accent: 'sky', + title: 'The postcode is the part you keep.', + body: 'Kitchens can change. Context cannot.', + }), + ], + }, + { + name: 'ad-04-before-rightmove', + city: 'manchester', + cues: [ + adCue('Before I open property tabs, I do this.', { + mode: 'tabs', + accent: 'teal', + kicker: 'Search ritual', + title: 'Before Rightmove', + body: 'Choose the areas. Then look at homes.', + items: [ + { label: 'Budget', tone: 'good' }, + { label: 'Commute', tone: 'good' }, + { label: 'Schools', tone: 'neutral' }, + { label: 'Quiet streets', tone: 'neutral' }, + ], + }), + adCue('Otherwise the listings choose the search for you.', { + mode: 'scanner', + accent: 'violet', + title: 'Tabs are not a strategy.', + items: [ + { label: '37 saved homes', value: 'chaos', tone: 'bad' }, + { label: '0 area shortlist', value: 'risky', tone: 'warn' }, + ], + }), + adCue('Start with the postcode map instead.', { + mode: 'title', + accent: 'lime', + title: 'Area first. Listings second.', + footer: 'Build the shortlist', + }), + ], + }, + { + name: 'ad-05-estate-agent-translator', + city: 'london', + cues: [ + adCue('Estate agent phrase translator, postcode edition.', { + mode: 'polygraph', + accent: 'sky', + kicker: 'Translator', + title: 'What does it actually mean?', + items: [ + { label: 'Vibrant area', value: 'check noise', tone: 'warn' }, + { label: 'Great links', value: 'time it', tone: 'neutral' }, + { label: 'Up and coming', value: 'price trend', tone: 'neutral' }, + ], + }), + adCue('Nice words are not local evidence.', { + mode: 'receipt', + accent: 'amber', + title: 'Run the translation', + items: [ + { label: 'Sold prices', value: 'facts', tone: 'good' }, + { label: 'Crime rate', value: 'facts', tone: 'good' }, + { label: 'Schools', value: 'facts', tone: 'good' }, + ], + }), + adCue('Translate the postcode before you trust the copy.', { + mode: 'title', + accent: 'teal', + title: 'Decode the area.', + body: 'Then decide if the listing deserves your time.', + }), + ], + }, + { + name: 'ad-06-manchester-300k-map', + city: 'manchester', + travelTimeFilters: [ + { + mode: 'transit', + slug: 'manchester', + label: 'Manchester city centre', + max: 35, + }, + ], + cues: [ + adCue( + 'This is the three hundred grand Manchester map nobody shows you.', + { + mode: 'title', + accent: 'teal', + kicker: 'Manchester', + title: 'Under £300k. Within 35 minutes.', + body: 'The useful places are not always the obvious ones.', + }, + adDrift() + ), + adCue('Every highlighted area earns its place.', { + mode: 'rank', + accent: 'lime', + title: 'The filter stack', + items: [ + { label: 'Budget', value: 'under £300k', tone: 'good' }, + { label: 'Commute', value: '35 min', tone: 'good' }, + { label: 'Schools and crime', value: 'checked', tone: 'good' }, + ], + }), + adCue('Click through and find the gaps.', { + mode: 'comment', + accent: 'sky', + comment: 'The obvious search area is not always the smartest one.', + title: 'Find the gaps.', + }), + ], + }, + { + name: 'ad-07-comment-a-city', + city: 'bristol', + cues: [ + adCue('Comment a city. We will expose the hidden postcodes.', { + mode: 'comment', + accent: 'violet', + comment: 'Can you do Bristol next?', + title: 'Yes. Watch the map.', + }), + adCue('The best shortlist starts with constraints, not vibes.', { + mode: 'tabs', + accent: 'teal', + title: 'City request loaded', + items: [ + { label: 'Budget', tone: 'good' }, + { label: 'Commute', tone: 'good' }, + { label: 'Schools', tone: 'neutral' }, + { label: 'Noise', tone: 'neutral' }, + ], + }), + adCue('Tap the link and search yours.', { + mode: 'title', + accent: 'lime', + title: 'Your city next.', + body: 'The map is already there.', + }), + ], + }, + { + name: 'ad-08-buying-a-postcode', + city: 'leeds', + cues: [ + adCue('You are not just buying a home.', { + mode: 'title', + accent: 'sky', + title: 'You are buying a postcode.', + body: 'The school run. The train. The road outside.', + }), + adCue('The area decides more of your week than the kitchen does.', { + mode: 'stack', + accent: 'amber', + title: 'What comes with the keys', + items: [ + { label: 'Commute', value: 'every day', tone: 'warn' }, + { label: 'Schools', value: 'every term', tone: 'neutral' }, + { label: 'Noise', value: 'every night', tone: 'bad' }, + ], + }), + adCue('Know the postcode before you chase the listing.', { + mode: 'title', + accent: 'teal', + title: 'Buy the context first.', + footer: 'Then book the viewing', + }), + ], + }, + { + name: 'ad-09-postcode-polygraph', + city: 'liverpool', + cues: [ + adCue('Let the postcode polygraph test the listing.', { + mode: 'polygraph', + accent: 'rose', + kicker: 'Polygraph', + title: 'Listing claims on trial', + progress: 0.78, + items: [ + { label: 'Quiet street', value: 'testing', tone: 'warn' }, + { label: 'Family friendly', value: 'testing', tone: 'warn' }, + { label: 'Easy commute', value: 'testing', tone: 'warn' }, + ], + }), + adCue('Some claims pass. Some need evidence.', { + mode: 'receipt', + accent: 'sky', + title: 'Evidence beats adjectives', + items: [ + { label: 'Noise', value: 'measured', tone: 'good' }, + { label: 'Crime', value: 'measured', tone: 'good' }, + { label: 'Sold prices', value: 'measured', tone: 'good' }, + ], + }), + adCue('Run the test before you get attached.', { + mode: 'title', + accent: 'teal', + title: 'Do not trust the adjectives.', + body: 'Check the postcode.', + }), + ], + }, + { + name: 'ad-10-two-streets-apart', + city: 'sheffield', + cues: [ + adCue('These homes are only two streets apart.', { + mode: 'split', + accent: 'amber', + kicker: 'Two streets', + title: 'Same area?', + left: { + title: 'Street one', + subtitle: 'Quieter, better school access, cleaner commute.', + meta: 'Stronger fit', + tone: 'good', + }, + right: { + title: 'Street two', + subtitle: 'More road noise, weaker context, same price bracket.', + meta: 'Needs checking', + tone: 'warn', + }, + }), + adCue('A tiny move can change the whole life around it.', { + mode: 'scanner', + accent: 'rose', + title: '400 metres can matter.', + body: 'Postcodes are local. Really local.', + }), + adCue('Zoom in before you choose.', { + mode: 'title', + accent: 'lime', + title: 'Do not search too wide.', + footer: 'Check street-level context', + }), + ], + }, + { + name: 'ad-11-boring-data-saves', + city: 'london', + cues: [ + adCue('This is the boring data that saves expensive mistakes.', { + mode: 'title', + accent: 'teal', + title: 'Boring data. Expensive consequences.', + body: 'Noise. Crime. Commute. Schools. Sold prices.', + }), + adCue('It is not glamorous. It is the bit you live with.', { + mode: 'receipt', + accent: 'sky', + title: 'Before the offer', + items: [ + { label: 'Sold prices', value: 'checked', tone: 'good' }, + { label: 'Commute', value: 'checked', tone: 'good' }, + { label: 'Noise and crime', value: 'checked', tone: 'good' }, + ], + }), + adCue('Check it before the viewing costs you a Saturday.', { + mode: 'title', + accent: 'amber', + title: 'Spend five seconds now.', + body: 'Save wasted viewings later.', + }), + ], + }, + { + name: 'ad-12-search-your-life', + city: 'bristol', + cues: [ + adCue('Stop searching houses. Search your life.', { + mode: 'tabs', + accent: 'violet', + kicker: 'POV', + title: 'Search the week you actually live.', + items: [ + { label: 'School run', tone: 'good' }, + { label: 'Station', tone: 'good' }, + { label: 'Parks', tone: 'neutral' }, + { label: 'Quiet street', tone: 'neutral' }, + ], + }), + adCue('The right postcode is the one that fits your days.', { + mode: 'match', + accent: 'lime', + title: 'Life fit score', + progress: 0.86, + items: [ + { label: 'Budget', value: 'fits', tone: 'good' }, + { label: 'Commute', value: 'fits', tone: 'good' }, + { label: 'Noise', value: 'check', tone: 'warn' }, + ], + }), + adCue('Describe the life. Then find the areas.', { + mode: 'title', + accent: 'teal', + title: 'Your shortlist should feel specific.', + }), + ], + }, + { + name: 'ad-13-postcode-leaderboard', + city: 'birmingham', + cues: [ + adCue('Here is the postcode leaderboard I wish buyers had.', { + mode: 'rank', + accent: 'sky', + kicker: 'Leaderboard', + title: 'Overlooked areas near Birmingham', + items: [ + { label: 'Best budget fit', value: 'hidden', tone: 'good' }, + { label: 'Best commute fit', value: 'hidden', tone: 'good' }, + { label: 'Best family fit', value: 'hidden', tone: 'good' }, + ], + }), + adCue('The names matter less than the filters.', { + mode: 'receipt', + accent: 'amber', + title: 'Every rank needs proof', + items: [ + { label: 'Prices', value: 'real sales', tone: 'good' }, + { label: 'Schools', value: 'nearby', tone: 'neutral' }, + { label: 'Crime', value: 'context', tone: 'neutral' }, + ], + }), + adCue('Open the map and build your own leaderboard.', { + mode: 'title', + accent: 'teal', + title: 'Your top five will be different.', + footer: 'That is the point', + }), + ], + }, + { + name: 'ad-14-hidden-commute-tax', + city: 'leeds', + travelTimeFilters: [ + { + mode: 'transit', + slug: 'leeds', + label: 'Leeds city centre', + max: 40, + }, + ], + cues: [ + adCue('Two similar homes. One has a hidden commute tax.', { + mode: 'split', + accent: 'rose', + kicker: 'Commute tax', + title: 'Same price. Different week.', + left: { + title: 'Home A', + subtitle: '28 minutes each way.', + meta: 'Worth a look', + tone: 'good', + }, + right: { + title: 'Home B', + subtitle: '67 minutes each way.', + meta: 'Costs your evenings', + tone: 'bad', + }, + }), + adCue('Cheap can become expensive in hours.', { + mode: 'receipt', + accent: 'amber', + title: 'The weekly bill', + items: [ + { label: 'Extra travel', value: '6h 30m', tone: 'bad' }, + { label: 'Stress', value: 'daily', tone: 'warn' }, + { label: 'Price saving', value: 'questionable', tone: 'warn' }, + ], + }), + adCue('Check the real commute before the viewing.', { + mode: 'title', + accent: 'teal', + title: 'Do not pay in time.', + footer: 'Map it first', + }), + ], + }, + { + name: 'ad-15-tab-chaos', + city: 'manchester', + cues: [ + adCue('If your house search has thirty seven tabs open, watch this.', { + mode: 'tabs', + accent: 'amber', + kicker: 'Tab chaos', + title: '37 tabs. No shortlist.', + items: [ + { label: 'Nice kitchen', tone: 'neutral' }, + { label: 'Maybe commute?', tone: 'warn' }, + { label: 'School unknown', tone: 'warn' }, + { label: 'Area unknown', tone: 'bad' }, + ], + }), + adCue('You do not need more listings. You need fewer areas.', { + mode: 'scanner', + accent: 'sky', + title: 'Collapse the chaos.', + body: 'Filter the postcode map first.', + }), + adCue('Then open the listings that actually matter.', { + mode: 'title', + accent: 'lime', + title: 'Fewer tabs. Better viewings.', + }), + ], + }, + { + name: 'ad-16-postcode-blind-date', + city: 'liverpool', + cues: [ + adCue('Postcode blind date. Let us see if it is compatible.', { + mode: 'match', + accent: 'violet', + kicker: 'Blind date', + title: 'Is this postcode your type?', + progress: 0.62, + items: [ + { label: 'Budget', value: 'yes', tone: 'good' }, + { label: 'Schools', value: 'yes', tone: 'good' }, + { label: 'Commute', value: 'questionable', tone: 'warn' }, + ], + }), + adCue('Good photos do not mean good compatibility.', { + mode: 'receipt', + accent: 'rose', + title: 'Compatibility check', + items: [ + { label: 'Noise', value: 'ask again', tone: 'warn' }, + { label: 'Crime', value: 'compare', tone: 'neutral' }, + { label: 'Sold prices', value: 'verify', tone: 'neutral' }, + ], + }), + adCue('Meet better postcodes.', { + mode: 'title', + accent: 'teal', + title: 'Swipe with evidence.', + body: 'Search the map before the listing feed.', + }), + ], + }, + { + name: 'ad-17-cannot-renovate', + city: 'bristol', + cues: [ + adCue('Everyone checks the kitchen. Nobody checks this.', { + mode: 'title', + accent: 'amber', + kicker: 'Buyer mistake', + title: 'You can renovate the kitchen.', + body: 'You cannot renovate the postcode.', + }), + adCue('The area comes with the house.', { + mode: 'receipt', + accent: 'sky', + title: 'Non-renovatable parts', + items: [ + { label: 'Commute', value: 'fixed', tone: 'warn' }, + { label: 'School access', value: 'fixed', tone: 'warn' }, + { label: 'Road noise', value: 'fixed', tone: 'bad' }, + ], + }), + adCue('Check the thing you cannot change.', { + mode: 'title', + accent: 'teal', + title: 'Before the viewing.', + footer: 'Run the postcode check', + }), + ], + }, + { + name: 'ad-18-quiet-streets-city', + city: 'london', + cues: [ + adCue('Quiet streets near the city do exist.', { + mode: 'scanner', + accent: 'lime', + kicker: 'Quiet search', + title: 'Find the calmer pockets.', + body: 'Near enough to live. Far enough to breathe.', + }), + adCue('But you need to search for noise, not just price.', { + mode: 'receipt', + accent: 'sky', + title: 'Quiet is a filter', + items: [ + { label: 'Road noise', value: 'lower', tone: 'good' }, + { label: 'Commute', value: 'kept', tone: 'good' }, + { label: 'Budget', value: 'still real', tone: 'neutral' }, + ], + }), + adCue('Search quieter before you settle louder.', { + mode: 'title', + accent: 'teal', + title: 'Quiet is searchable.', + }), + ], + }, + { + name: 'ad-19-red-flag-generator', + city: 'sheffield', + cues: [ + adCue('The postcode red flag generator is brutal.', { + mode: 'scanner', + accent: 'rose', + kicker: 'Red flags', + title: 'Cheap for a reason?', + items: [ + { label: 'Commute trap', value: 'possible', tone: 'bad' }, + { label: 'Main-road noise', value: 'possible', tone: 'bad' }, + { label: 'School gamble', value: 'possible', tone: 'warn' }, + ], + }), + adCue('That is exactly why you want it before the viewing.', { + mode: 'receipt', + accent: 'amber', + title: 'Better to know early', + items: [ + { label: 'Bad fit', value: 'skip', tone: 'good' }, + { label: 'Good fit', value: 'shortlist', tone: 'good' }, + { label: 'Unknown fit', value: 'investigate', tone: 'warn' }, + ], + }), + adCue('Run the red flag check.', { + mode: 'title', + accent: 'teal', + title: 'Let the map be picky.', + }), + ], + }, + { + name: 'ad-20-where-wed-live', + city: 'manchester', + cues: [ + adCue('We asked the map where we would actually live.', { + mode: 'comment', + accent: 'sky', + comment: 'Not prettiest. Not trendiest. Just what fits.', + title: 'Where would we actually live?', + }), + adCue('The answer was not the obvious search area.', { + mode: 'rank', + accent: 'lime', + title: 'The shortlist changed', + items: [ + { label: 'Better commute', value: 'found', tone: 'good' }, + { label: 'Lower noise', value: 'found', tone: 'good' }, + { label: 'Budget fit', value: 'found', tone: 'good' }, + ], + }), + adCue('Build the shortlist before the listings take over.', { + mode: 'title', + accent: 'teal', + title: 'Ask the map first.', + body: 'Then decide what deserves a viewing.', + }), + ], + }, +]; + +const AD_STORYBOARDS = AD_CONFIGS.map(createAdStoryboard); + +const STORYBOARD_SET = process.env.VIDEO_STORYBOARD_SET ?? 'ads'; + +export const storyboards: Storyboard[] = + STORYBOARD_SET === 'demo' + ? DEMO_STORYBOARDS + : STORYBOARD_SET === 'all' + ? [...AD_STORYBOARDS, ...DEMO_STORYBOARDS] + : AD_STORYBOARDS; + export function getStoryboard(name: string): Storyboard { const sb = storyboards.find((s) => s.name === name); if (!sb) { diff --git a/video/src/verify.ts b/video/src/verify.ts index 053535c..ff36848 100644 --- a/video/src/verify.ts +++ b/video/src/verify.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, statSync } from 'node:fs'; import { OUTPUT_DIR } from './config.js'; -import { viewportFor, type Storyboard } from './script.js'; +import { recordedSizeFor, type Storyboard } from './script.js'; import { getStoryboard } from './storyboard.js'; interface Probe { @@ -58,7 +58,7 @@ function verifyVideo(path: string, storyboard: Storyboard) { const stream = data.streams?.[0]; if (!stream) fail(`${path} has no video stream`); - const expectedSize = viewportFor(storyboard.video); + const expectedSize = recordedSizeFor(storyboard.video); const { minDurationS, maxDurationS, outputFps } = storyboard.video; const duration = Number(data.format?.duration ?? 0);