From 716e42a57dfdd84645b1ea1a6653811c47ab1aeb Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 14 Jul 2026 16:22:23 +0100 Subject: [PATCH] Improve vide --- video/src/dashboard.ts | 157 ++++++++++++++++++++++++---------- video/src/dom.ts | 38 ++++++--- video/src/motion.ts | 40 ++++++++- video/src/runner.ts | 23 ++++- video/src/script.ts | 32 ++++++- video/src/storyboard.ts | 184 ++++++++++++++++++++++++++++------------ video/src/timeline.ts | 5 +- 7 files changed, 358 insertions(+), 121 deletions(-) diff --git a/video/src/dashboard.ts b/video/src/dashboard.ts index 1fe059b..79c89c0 100644 --- a/video/src/dashboard.ts +++ b/video/src/dashboard.ts @@ -1,5 +1,6 @@ import type { Page, Request, Response } from 'playwright'; import { waitForAnimationFrames } from './dom.js'; +import type { HexPreference } from './script.js'; interface Bounds { south: number; @@ -129,7 +130,10 @@ export class DashboardRecorder { await this.waitForStable({ afterSelectionVersion, timeoutMs }); } - async visibleHexagonTargets(limit = 8): Promise { + async visibleHexagonTargets( + limit = 8, + prefer?: HexPreference + ): 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. @@ -146,7 +150,7 @@ export class DashboardRecorder { ); } } - const postcodeTargets = await this.visiblePostcodeTargets(limit); + const postcodeTargets = await this.visiblePostcodeTargets(limit, prefer); if (postcodeTargets.length > 0) return postcodeTargets; const snapshot = this.lastHexagons; @@ -175,32 +179,13 @@ export class DashboardRecorder { y: point.y, count: feature.count, score: feature.count / (1 + distanceFromCenter * 0.25), + distanceFromCenter, + prefValue: prefer ? asFiniteNumber(feature[`avg_${prefer.field}`]) : null, }; }) - .filter((target): target is HexagonClickTarget & { score: number } => target != null); + .filter((target): target is ScoredTarget => target != null); - const onScreen = projected.filter( - (target) => - target.x >= mapBox.x && - target.x <= mapBox.x + mapBox.width && - target.y >= mapBox.y && - target.y <= mapBox.y + mapBox.height - ); - const clearOfChrome = onScreen.filter( - (target) => - target.x >= clear.left && - target.x <= clear.right && - target.y >= clear.top && - target.y <= clear.bottom - ); - - const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort( - (a, b) => b.score - a.score - ); - if (candidates.length === 0) { - throw new Error('No visible hexagons from the latest map response can be clicked'); - } - return candidates.slice(0, limit).map(({ score: _score, ...target }) => target); + return finishCandidates(projected, mapBox, clear, limit, prefer); } /** @@ -347,7 +332,10 @@ export class DashboardRecorder { return !loading && !connecting; } - private async visiblePostcodeTargets(limit: number): Promise { + private async visiblePostcodeTargets( + limit: number, + prefer?: HexPreference + ): Promise { const snapshot = this.lastPostcodes; if (!snapshot || snapshot.features.length === 0) return []; @@ -373,32 +361,109 @@ export class DashboardRecorder { y: point.y, count: feature.properties.count, score: feature.properties.count / (1 + distanceFromCenter * 0.25), + distanceFromCenter, + prefValue: prefer + ? asFiniteNumber(feature.properties[`avg_${prefer.field}`]) + : null, }; }) - .filter((target): target is HexagonClickTarget & { score: number } => target != null); + .filter((target): target is ScoredTarget => target != null); - const onScreen = projected.filter( - (target) => - target.x >= mapBox.x && - target.x <= mapBox.x + mapBox.width && - target.y >= mapBox.y && - target.y <= mapBox.y + mapBox.height - ); - const clearOfChrome = onScreen.filter( - (target) => - target.x >= clear.left && - target.x <= clear.right && - target.y >= clear.top && - target.y <= clear.bottom - ); - - const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort( - (a, b) => b.score - a.score - ); - return candidates.slice(0, limit).map(({ score: _score, ...target }) => target); + if (projected.length === 0) return []; + return finishCandidates(projected, mapBox, clear, limit, prefer); } } +interface ScoredTarget extends HexagonClickTarget { + score: number; + distanceFromCenter: number; + /** `avg_` from the map response when a preference is in play. */ + prefValue: number | null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * Shared candidate ordering for hexagon/postcode targets: constrain to the + * on-screen chrome-free area, then rank. Without a preference the ranking is + * the classic crowd-and-centre score. With one, candidates are ranked by + * their `avg_` value (normalised across the visible set) with a mild + * centre bias and a nudge away from near-empty polygons, so a "cheapest + * nearby" zoom lands on the green end of the coloured ramp instead of the + * busiest (often priciest) block. + */ +function finishCandidates( + projected: ScoredTarget[], + mapBox: { x: number; y: number; width: number; height: number }, + clear: { top: number; bottom: number; left: number; right: number }, + limit: number, + prefer?: HexPreference +): HexagonClickTarget[] { + const onScreen = projected.filter( + (target) => + target.x >= mapBox.x && + target.x <= mapBox.x + mapBox.width && + target.y >= mapBox.y && + target.y <= mapBox.y + mapBox.height + ); + const clearOfChrome = onScreen.filter( + (target) => + target.x >= clear.left && + target.x <= clear.right && + target.y >= clear.top && + target.y <= clear.bottom + ); + const pool = clearOfChrome.length > 0 ? clearOfChrome : onScreen; + if (pool.length === 0) { + throw new Error('No visible hexagons from the latest map response can be clicked'); + } + + const strip = ({ + score: _score, + distanceFromCenter: _d, + prefValue: _v, + ...target + }: ScoredTarget): HexagonClickTarget => target; + + if (prefer) { + const valued = pool.filter((c) => c.prefValue != null); + // Need a real spread to rank by value; degenerate/missing data falls + // back to the crowd score rather than chasing one noisy polygon. + if (valued.length >= 3) { + const values = valued.map((c) => c.prefValue as number); + const lo = Math.min(...values); + const hi = Math.max(...values); + const span = hi - lo || 1; + const ranked = valued + .map((c) => { + const norm = ((c.prefValue as number) - lo) / span; + const directional = prefer.direction === 'min' ? norm : 1 - norm; + const penalty = 0.2 * c.distanceFromCenter + (c.count < 3 ? 0.25 : 0); + return { c, key: directional + penalty }; + }) + .sort((a, b) => a.key - b.key); + const best = ranked[0].c; + console.log( + `[dashboard] prefer ${prefer.direction} "${prefer.field}": picked ${best.h3} ` + + `(avg ${best.prefValue}, count ${best.count}) from ${valued.length} valued ` + + `candidates spanning ${lo.toFixed(1)}..${hi.toFixed(1)}` + ); + return ranked.slice(0, limit).map(({ c }) => strip(c)); + } + console.log( + `[dashboard] prefer "${prefer.field}" requested but only ${valued.length} ` + + `candidates carry avg_ values; falling back to crowd scoring` + ); + } + + return pool + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(strip); +} + function classifyApiRequest(rawUrl: string): ApiKind | null { let path: string; try { diff --git a/video/src/dom.ts b/video/src/dom.ts index 3a6338e..26fce96 100644 --- a/video/src/dom.ts +++ b/video/src/dom.ts @@ -553,13 +553,15 @@ export async function installCursor( caption.id = '__demo-caption'; document.body.appendChild(caption); - window.addEventListener( - 'mousemove', - (e) => { - cursor.style.transform = `translate(${e.clientX - hot}px, ${e.clientY - hot}px)`; - }, - { passive: true, capture: true } - ); + const followPointer = (e: { clientX: number; clientY: number }) => { + cursor.style.transform = `translate(${e.clientX - hot}px, ${e.clientY - hot}px)`; + }; + window.addEventListener('mousemove', followPointer, { passive: true, capture: true }); + // ALSO track pointermove: Radix sliders preventDefault() their + // pointerdown, which suppresses the derived mousemove events for the + // whole drag, so a mousemove-only cursor froze mid-track while the + // thumb kept moving. Pointer events always fire. + window.addEventListener('pointermove', followPointer, { passive: true, capture: true }); (window as typeof window & { __demoMoveCursor?: (x: number, y: number, durationMs: number) => void; }).__demoMoveCursor = (x, y, durationMs) => { @@ -986,10 +988,17 @@ export async function scrollPaneTo( selector: string, top: number ): Promise { - await page.evaluate( - ({ selector, top }) => { - const el = document.querySelector(selector) as HTMLElement | null; - if (!el) return; + // Resolve via a Playwright locator (not in-page querySelector) so + // storyboards can anchor on locator-only selectors like + // `button:has-text("Schools")` to reach a specific scroll container. + const handle = await page + .locator(selector) + .first() + .elementHandle({ timeout: 2000 }) + .catch(() => null); + if (!handle) return; + await handle.evaluate( + (el, top) => { const findScrollable = (node: HTMLElement | null): HTMLElement | null => { let n: HTMLElement | null = node; while (n) { @@ -1001,15 +1010,16 @@ export async function scrollPaneTo( }; // Look both inside (for the actual scroll container deeper in the tree) // and outwards. + const root = el as HTMLElement; const inner = - Array.from(el.querySelectorAll('*')).find((n) => { + Array.from(root.querySelectorAll('*')).find((n) => { const oy = getComputedStyle(n).overflowY; return (oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight; }) ?? null; - const target = inner ?? findScrollable(el) ?? el; + const target = inner ?? findScrollable(root) ?? root; target.scrollTo({ top, behavior: 'smooth' }); }, - { selector, top } + top ); } diff --git a/video/src/motion.ts b/video/src/motion.ts index 812444f..ed395bb 100644 --- a/video/src/motion.ts +++ b/video/src/motion.ts @@ -131,7 +131,8 @@ export async function smoothDragSliderThumb( trackSelector: string, fromCursor: { x: number; y: number }, toFraction: number, - durationMs = 520 + durationMs = 520, + snapToValue?: number ): Promise<{ x: number; y: number }> { const thumbBox = await page.locator(thumbSelector).boundingBox(); const trackBox = await page.locator(trackSelector).boundingBox(); @@ -145,12 +146,45 @@ export async function smoothDragSliderThumb( await page.mouse.down(); // The user explicitly prefers a longer render over stepped motion, so use // enough real pointer updates for the thumb itself to read as continuous. + const dragMs = snapToValue == null ? durationMs : Math.max(200, durationMs - 400); await smoothMove( page, { x: thumbCx, y: thumbCy }, { x: targetX, y: thumbCy }, - { durationMs, realMouse: true } + { durationMs: dragMs, realMouse: true } ); + + // Converge on the exact value WHILE the pointer is still down. A pixel + // drop lands a few steps off (thumb inset + snapping): the "20 minutes" + // drag settled on 15 and the UI contradicted the narration. Keyboard + // nudging after mouseup doesn't work here: the app's slider only accepts + // value changes inside a pointer session (onPointerDown starts the drag + // state, onPointerUp commits), so the correction must be pointer-driven. + let finalX = targetX; + if (snapToValue != null) { + const thumb = page.locator(thumbSelector); + const readAttr = async (name: string): Promise => { + const raw = await thumb.getAttribute(name).catch(() => null); + const value = raw == null ? NaN : Number(raw); + return Number.isFinite(value) ? value : null; + }; + const min = await readAttr('aria-valuemin'); + const max = await readAttr('aria-valuemax'); + const pxPerUnit = + min != null && max != null && max > min ? trackBox.width / (max - min) : null; + if (pxPerUnit != null) { + for (let i = 0; i < 24; i++) { + const now = await readAttr('aria-valuenow'); + if (now == null || now === snapToValue) break; + let dx = (snapToValue - now) * pxPerUnit; + dx = Math.max(-60, Math.min(60, dx)); + if (Math.abs(dx) < 1) dx = Math.sign(snapToValue - now); + finalX += dx; + await page.mouse.move(finalX, thumbCy, { steps: 3 }); + await sleep(50); + } + } + } await page.mouse.up(); - return { x: targetX, y: thumbCy }; + return { x: finalX, y: thumbCy }; } diff --git a/video/src/runner.ts b/video/src/runner.ts index 1f56229..3811739 100644 --- a/video/src/runner.ts +++ b/video/src/runner.ts @@ -64,6 +64,15 @@ export async function runStoryboard( narrationLog.reset(); const synth = loadSynthIndex(storyboard); + + // Unrecorded staging: everything here happens BEFORE the trim anchor, so + // the published video cold-opens on the state these steps leave behind + // (e.g. ad-02's travel-time colouring + collapsed sheet). `pre` runs after + // the anchor and is therefore on camera. + for (const step of storyboard.setup ?? []) { + await runStep(ctx, step); + } + const sceneStartMs = Date.now(); const leadInMs = LEAD_IN_S * 1000; const cursor = { ms: 0 }; @@ -198,7 +207,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { } const candidates = step.target.kind === 'hexagon' && step.waitForSelectionReady - ? await ctx.dashboard.visibleHexagonTargets(4) + ? await ctx.dashboard.visibleHexagonTargets(4, step.target.prefer) : [await resolveTarget(ctx, step.target)]; let lastError: unknown = null; @@ -235,6 +244,13 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { await fakeType(ctx.page, step.selector, step.text, step.durationMs); return; case 'mapZoom': { + // Hexagon-anchored zooms project against the latest map response; if a + // sheet-collapse or colour toggle still has fetches in flight, the + // candidate list is stale and a value-preferring zoom can chase a + // polygon from the wrong viewport. + if (step.target.kind === 'hexagon') { + await ctx.dashboard.waitForApiIdle(3000); + } const point = await resolveTarget(ctx, step.target); const mapVersion = ctx.dashboard.getMapDataVersion(); const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA; @@ -361,7 +377,8 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise { step.trackSelector, ctx.cursor, step.toFraction, - step.durationMs + step.durationMs, + step.snapToValue ); if (step.waitForMapSettled) { await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000); @@ -449,7 +466,7 @@ async function resolveTarget( ): Promise<{ x: number; y: number }> { if (target.kind === 'point') return { x: target.x, y: target.y }; if (target.kind === 'hexagon') { - const targets = await ctx.dashboard.visibleHexagonTargets(1); + const targets = await ctx.dashboard.visibleHexagonTargets(1, target.prefer); if (targets.length === 0) throw new Error('No visible hexagon to target'); return { x: targets[0].x, y: targets[0].y }; } diff --git a/video/src/script.ts b/video/src/script.ts index fd4b81a..3b94ed8 100644 --- a/video/src/script.ts +++ b/video/src/script.ts @@ -75,6 +75,20 @@ export interface AdScene { transparent?: boolean; } +/** + * Value preference for hexagon targets. When set, the target resolves to the + * visible polygon whose `avg_` value from the latest map response is + * lowest (or highest), instead of the most-crowded one. This is what keeps a + * "lower rent nearby" payoff zoom from centring on the most EXPENSIVE + * polygon just because it has the most properties: with the map coloured by + * the metric, the camera must land on the green end of the ramp. + */ +export interface HexPreference { + /** Live /api/features name; the response carries it as `avg_`. */ + field: string; + direction: 'min' | 'max'; +} + /** A point on screen, resolved at runtime to viewport pixels. */ export type Target = | { kind: 'point'; x: number; y: number } @@ -85,7 +99,7 @@ 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'; prefer?: HexPreference } /** * Resolved at runtime to (xFrac·viewport.width, yFrac·viewport.height). Use * this when an activity needs a stable visual location that scales with the @@ -97,7 +111,7 @@ export type Target = 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 hex = (prefer?: HexPreference): Target => ({ kind: 'hexagon', prefer }); export const vfrac = (xFrac: number, yFrac: number): Target => ({ kind: 'viewportFraction', xFrac, @@ -160,6 +174,13 @@ export type Activity = trackSelector: string; toFraction: number; durationMs: number; + /** + * Exact slider value to land on. Pixel-fraction drops drift by a few + * steps (thumb inset + snap), which put "15 min" on screen under a + * caption claiming 20. After the drop the runner nudges the focused + * thumb with arrow keys until aria-valuenow matches. + */ + snapToValue?: number; waitForMapSettled?: boolean; timeoutMs?: number; } @@ -350,6 +371,13 @@ export interface Storyboard { video: VideoConfig; voice: VoiceConfig; content: ContentConfig; + /** + * UNRECORDED staging. Runs before the trim anchor, so the published video + * cold-opens on whatever state these steps leave behind (colour layers, + * collapsed sheet, parked cursor). Use `pre` instead for anything that + * should be seen happening (e.g. the vignette fade). + */ + setup?: Activity[]; pre?: Activity[]; cues: Cue[]; post?: Activity[]; diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 51c574a..3066f76 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -1,4 +1,5 @@ import { + at, el, hex, vfrac, @@ -679,6 +680,13 @@ interface DemoAdStoryboardConfig { center?: { lat: number; lon: number }; /** Plausible per-city "{{count}} properties match" total for the AI summary. */ matchCount?: number; + /** + * UNRECORDED staging (Storyboard.setup): runs before the trim anchor so + * the ad cold-opens on this state. Colour layers, sheet position, cursor + * parking all belong here; leaking them into the first frames read as a + * recording that started too early. + */ + preStage?: Activity[]; /** Activities to run before the cue loop starts (silent, keep short). */ prePrime?: Activity[]; /** Spoken line during the outro card. Must NOT repeat the card's text. */ @@ -757,11 +765,26 @@ const submitSettled = (durationMs = 1400, timeoutMs = 10000): Activity => ({ waitForMapSettled: true, timeoutMs, }); +/** + * Submit WITHOUT waiting for the map to settle. Used by the colour-reveal + * beats: the filter cards mount as soon as the (stubbed) AI response lands, + * so the Colour-map click can follow ~1s after submit instead of sitting + * behind a 2s settle wait while the caption already promises colours. The + * following payoff zoom is settled, which is where convergence matters. + */ +const submitAct = (durationMs = 600): Activity => ({ + kind: 'submitForm', + formSelector: AI_FORM, + durationMs, +}); 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, + // Land on the exact minute the narration claims; the raw pixel drop + // drifted to 15 when the story said 20. + snapToValue: toMin, durationMs, }); const wait = (durationMs: number): Activity => ({ kind: 'wait', durationMs }); @@ -798,23 +821,28 @@ const tapHex = (durationMs = 1000): Activity => ({ * of asserted. IfVisible + generous timeout so a missing card costs the * colour, not the take; the runner scrolls the card into view first. */ -const colourMapAct = (featureName: string, durationMs = 700): Activity => ({ +const colourMapAct = (featureName: string, durationMs = 600): Activity => ({ kind: 'clickIfVisible', target: el(`[data-filter-name="${featureName}"] button[title="Colour map"]`), durationMs, - timeoutMs: 2500, + timeoutMs: 1200, }); /** - * The final "the value is right here" zoom. Anchored on the strongest - * visible match (hex()) and centered, so the camera always lands on - * populated, matching postcodes. The old fixed-pixel zoom regularly dove - * into parks or filtered-out suburbs and the payoff frame read as an - * empty, broken map. + * The final "the value is right here" zoom. Anchored on a visible match + * (hex()) and centered, so the camera always lands on populated, matching + * postcodes. The old fixed-pixel zoom regularly dove into parks or + * filtered-out suburbs and the payoff frame read as an empty, broken map. + * + * `preferMinField`: when the map is coloured by a metric, pass that feature + * name so the camera centres on the LOWEST-value (green) polygon in view. + * Without it the zoom picks the most crowded polygon, which for price/rent + * is usually the red end of the ramp, directly contradicting a "cheaper + * nearby" line. */ -const payoffZoom = (durationMs = 1700, steps = 4): Activity => ({ +const payoffZoom = (durationMs = 1700, steps = 4, preferMinField?: string): Activity => ({ kind: 'mapZoom', - target: hex(), + target: hex(preferMinField ? { field: preferMinField, direction: 'min' } : undefined), steps, durationMs, center: true, @@ -830,6 +858,19 @@ const scrollDrawer = (top: number, durationMs = 800): Activity => ({ top, durationMs, }); +/** + * Scroll the drawer's REAL content pane. scrollPane resolves the first + * scrollable under its selector, and from the drawer root that is a shallow + * wrapper that maxes out after ~230px (which left the loading Street View + * pane parked mid-frame). Anchoring on a deep element (the Schools section + * header) makes the ancestor walk find the actual content scroller. + */ +const scrollDrawerDeep = (top: number, durationMs = 700): Activity => ({ + kind: 'scrollPane', + selector: `${RIGHT_PANE_SELECTOR} button:has-text("Schools")`, + top, + durationMs, +}); /** Default silent pre: vignette + hidden cursor; narration starts at t≈0. */ const AD_PRE: Activity[] = [ @@ -871,6 +912,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { brand: AD_BRAND, matchCount: ad.matchCount, }, + setup: ad.preStage, pre: ad.prePrime ?? AD_PRE, cues: [ ...ad.cues.map((cue, index) => ({ @@ -886,10 +928,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { text: ad.outroLine, gapBeforeMs: 200, during: [ - // Let the closer start over the live product; the brand card pops - // half a second in. A static card for the whole outro line plus a - // 2.2s hold was 6+ seconds of dead frame, a third of the ad. - wait(500), + // Hand the frame back to the live product for the first second of + // the closer (fading any receipt overlay), then pop the brand + // card. A static card for the whole outro line plus a 2.2s hold + // was 6+ seconds of dead frame, a third of the ad. + { kind: 'hideAdScene', durationMs: 200 }, + wait(800), { kind: 'showOutro', brand: AD_BRAND.name, @@ -902,7 +946,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard { }, ], // Just long enough to read the URL with sound off. - tail: [wait(1100)], + tail: [wait(700)], }, ], }; @@ -950,13 +994,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // instead of the same orange map with a smaller count. text: 'Every postcode in England that fits, sorted by value.', caption: 'All of England, by value', - during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)], + during: [submitAct(600), colourMapAct('Estimated current price'), wait(300)], tail: [sheetDown(700), wait(200)], }, { text: 'Same schools, same commute, the price quietly drops nearby.', caption: 'The cheaper twin nearby', - during: [payoffZoom(1600, 4)], + during: [payoffZoom(1600, 4, 'Estimated current price')], tail: [wait(300)], }, ], @@ -978,18 +1022,25 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ initialZoom: 10.2, posterTimeS: 9, outroLine: 'The commute is priced in. The bargain is not.', - prePrime: [ - { kind: 'clearVignette', durationMs: 0 }, + // Cold-open ON the rainbow: the colour toggle, the settle, and the + // sheet collapse all happen before the trim anchor. Recording them + // (the old prePrime) put an uncoloured map, a sliding sheet, and a + // stray hover tooltip into the first 1.8s and the poster frame. + preStage: [ { 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 }, + { kind: 'wait', durationMs: 900 }, sheetDown(700), - { kind: 'wait', durationMs: 200 }, + { kind: 'moveCursor', target: at(24, 14), durationMs: 200 }, + { kind: 'wait', durationMs: 500 }, + ], + prePrime: [ + { kind: 'clearVignette', durationMs: 0 }, + { kind: 'wait', durationMs: 120 }, ], cues: [ { @@ -1002,7 +1053,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ text: "Here's what twenty minutes actually leaves you.", caption: 'What 20 minutes buys', during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)], - tail: [touchHide(), sheetDown(700), wait(200)], + tail: [touchHide(), sheetDown(650)], }, { text: "Same twenty minutes wherever it's lit, but not the same price.", @@ -1028,11 +1079,17 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ initialZoom: 10.6, posterTimeS: 13, outroLine: 'Every postcode, proven on the numbers, not its reputation.', - prePrime: [ - { kind: 'clearVignette', durationMs: 0 }, + // Sheet collapse is staging, not story: do it before the trim anchor so + // the ad cold-opens on the full-bleed filtered map. + preStage: [ { kind: 'cursorScale', scale: 0, durationMs: 0 }, sheetDown(700), - { kind: 'wait', durationMs: 200 }, + { kind: 'moveCursor', target: at(24, 14), durationMs: 200 }, + { kind: 'wait', durationMs: 300 }, + ], + prePrime: [ + { kind: 'clearVignette', durationMs: 0 }, + { kind: 'wait', durationMs: 120 }, ], cues: [ { @@ -1055,28 +1112,30 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ tail: [wait(150)], }, { - // Breathe after the tap: the drawer needs a beat to render Street - // View + the price chart. Scrolling during that window put a blank - // white pane on screen, which read as the product breaking. + // The Street View iframe needs ~2s to paint after the drawer opens, + // and its empty white/black canvas read as the product breaking. + // Scroll PAST it to the price-history chart (which paints + // instantly) and let Street View load off-screen. text: 'Sold prices, schools, crime, even Street View, for this exact postcode.', caption: 'The whole postcode file', - during: [touchShow(), tapHex(1100), wait(600), scrollDrawer(420, 800)], + during: [touchShow(), tapHex(1100), scrollDrawerDeep(1150, 500)], tail: [wait(200)], }, { - // Open a real section instead of scrolling a wall of collapsed - // headers: expanded school data IS the "evidence" the line claims. + // Scroll back up to the (now-loaded) Street View photo, then expand + // the Schools section: real data on screen for the "evidence" line. text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.', caption: 'Same proof, less money', during: [ + scrollDrawerDeep(760, 600), + wait(500), { kind: 'clickIfVisible', target: el(`${RIGHT_PANE_SELECTOR} button:has-text("Schools")`), - durationMs: 700, + durationMs: 650, timeoutMs: 1500, }, - wait(300), - scrollDrawer(700, 900), + scrollDrawerDeep(1450, 800), touchHide(), ], tail: [wait(300)], @@ -1111,13 +1170,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // gets a visual instead of a subtly thinner orange map. text: 'This is London under fifty-five decibels.', caption: 'Under 55 decibels', - during: [submitSettled(1400), colourMapAct('Noise (dB)'), wait(300)], + during: [submitAct(600), colourMapAct('Noise (dB)'), wait(300)], tail: [sheetDown(700), wait(200)], }, { text: 'Nearby, just as quiet, and overlooked.', caption: 'Quiet, and overlooked', - during: [payoffZoom(1700, 4)], + during: [payoffZoom(1700, 4, 'Noise (dB)')], tail: [wait(300)], }, ], @@ -1153,13 +1212,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // pockets visible inside the school-filtered map. text: 'Everything still on the map passes all three filters.', caption: 'Passes all three', - during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)], + during: [submitAct(600), colourMapAct('Estimated current price'), wait(300)], tail: [sheetDown(700), wait(200)], }, { text: 'Same primary, same low crime, without the name everyone else is bidding up.', caption: 'Same catchment, less money', - during: [payoffZoom(1700, 4)], + during: [payoffZoom(1700, 4, 'Estimated current price')], tail: [wait(300)], }, ], @@ -1195,16 +1254,19 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ text: 'London, narrowed to the postcodes that fit the way you live.', caption: 'Fits how you live', during: [ - submitSettled(1400), - colourMapAct('Distance to nearest amenity (Waitrose) (km)'), - wait(300), + submitAct(600), + // Amenity-distance filters render inside the folded "Amenity + // distance" variant card; the raw feature name has no card of + // its own, which is why the first cut's colour click no-opped. + colourMapAct('Amenity distance'), + wait(500), ], tail: [sheetDown(700), wait(200)], }, { text: 'Same amenities, lower price nearby.', caption: 'Same life, lower price', - during: [payoffZoom(1500, 4)], + during: [payoffZoom(1500, 4, 'Distance to nearest amenity (Waitrose) (km)')], tail: [wait(300)], }, ], @@ -1217,9 +1279,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ name: 'ad-07-renters-map', matchCount: 2160, city: 'london', - promptText: 'Rent under £1,600, 30 min to central London, quiet street', + promptText: 'Rent under £2,000, 30 min to central London, quiet street', filters: { - 'Estimated monthly rent': [0, 1600], + // £2,000, not £1,600: the tighter cap crushed the visible rent + // spread to ~£40, so the colour ramp stretched noise into a full + // rainbow and the payoff could land on a "crimson" polygon that was + // £40 dearer. At £2k the ramp spans real money and green = cheaper. + 'Estimated monthly rent': [0, 2000], 'Noise (dB)': [0, 58], }, travelTimeFilters: [ @@ -1230,9 +1296,9 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ outroLine: 'The name costs more. The street does not.', cues: [ { - text: 'Rent under sixteen hundred, half an hour to central, on a quiet street.', + text: 'Rent under two grand, half an hour to central, on a quiet street.', caption: 'Stop renting the reputation', - during: [typeAct('Rent under £1,600, 30 min to central London, quiet street', 2800)], + during: [typeAct('Rent under £2,000, 30 min to central London, quiet street', 2800)], tail: [wait(150)], }, { @@ -1240,13 +1306,27 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ // line promises. text: 'Letting sites rank flats. This ranks the streets around them.', caption: 'Areas, not just flats', - during: [submitSettled(1400), colourMapAct('Estimated monthly rent'), wait(300)], + during: [submitAct(600), colourMapAct('Estimated monthly rent'), wait(300)], tail: [sheetDown(700), wait(200)], }, { text: 'Same commute, same quiet, lower rent nearby.', caption: 'Same street, lower rent', - during: [payoffZoom(1600, 4)], + during: [ + payoffZoom(1600, 4, 'Estimated monthly rent'), + { + // Second, gentle stage: the first zoom centres the centroid of a + // COARSE hex; once the fine grain streams in, drift onto the + // actual cheapest polygon so the green hero ends centre-frame. + kind: 'mapZoom', + target: hex({ field: 'Estimated monthly rent', direction: 'min' }), + steps: 1, + durationMs: 900, + center: true, + waitForMapSettled: true, + timeoutMs: 8000, + }, + ], tail: [wait(300)], }, ], @@ -1281,13 +1361,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [ { text: "You pay for the postcode's name; the value sits a postcode away.", caption: 'You pay for the name', - during: [submitSettled(1400), colourMapAct('Estimated current price'), wait(300)], + during: [submitAct(600), colourMapAct('Estimated current price'), wait(300)], tail: [sheetDown(700), wait(200)], }, { text: 'Pay once, find the bargain, stop overpaying for the name.', caption: 'Find the value instead', - during: [payoffZoom(1600, 4)], + during: [payoffZoom(1600, 4, 'Estimated current price')], tail: [wait(300)], }, ], @@ -1368,7 +1448,7 @@ const TWIN_PAIRS: TwinPair[] = [ savingSpoken: "That's more than a hundred thousand pounds back on an average home.", distMeta: '2.6 km apart', hook: - 'Stanmore and Kenton sit two and a half kilometres apart, with the same schools and transport links.', + 'Stanmore and Kenton sit about two and a half kilometres apart, with the same schools and transport links.', center: { lat: 51.57199, lon: -0.3079 }, initialZoom: 12.0, matchCount: 1620, @@ -1422,7 +1502,7 @@ function createTwinConfig(pair: TwinPair): DemoAdStoryboardConfig { { text: 'Now colour every postcode by what a square metre of home actually costs there.', caption: 'Coloured by £ per m²', - during: [submitSettled(1400), colourMapAct(PSQM_FEATURE), wait(300)], + during: [submitAct(600), colourMapAct(PSQM_FEATURE), wait(300)], tail: [sheetDown(700), wait(200)], }, { diff --git a/video/src/timeline.ts b/video/src/timeline.ts index 6d45c4b..f943ab9 100644 --- a/video/src/timeline.ts +++ b/video/src/timeline.ts @@ -32,7 +32,10 @@ export async function prepareTimeline( await installCursor(page, storyboard.video.cursorStyle ?? 'arrow'); await setAspectClass(page, storyboard.video.aspect); - const ctx: ScriptCtx = { page, dashboard, cursor: { x: 200, y: 240 } }; + // Park the mouse over the header bar, never the map: an idle pointer over + // a polygon summons the hover card, and on cold-open ads that tooltip was + // baked into frame 0 (and the poster/thumbnail). + const ctx: ScriptCtx = { page, dashboard, cursor: { x: 24, y: 14 } }; await page.mouse.move(ctx.cursor.x, ctx.cursor.y); // Only pre-open the AI prompt when the storyboard actually types into it. // Opening it unconditionally grows the mobile bottom sheet (keyboard