Improve vide

This commit is contained in:
Andras Schmelczer 2026-07-14 16:22:23 +01:00
parent e544b946c9
commit 716e42a57d
7 changed files with 358 additions and 121 deletions

View file

@ -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<HexagonClickTarget[]> {
async visibleHexagonTargets(
limit = 8,
prefer?: HexPreference
): Promise<HexagonClickTarget[]> {
// 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<HexagonClickTarget[]> {
private async visiblePostcodeTargets(
limit: number,
prefer?: HexPreference
): Promise<HexagonClickTarget[]> {
const snapshot = this.lastPostcodes;
if (!snapshot || snapshot.features.length === 0) return [];
@ -373,10 +361,46 @@ 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);
if (projected.length === 0) return [];
return finishCandidates(projected, mapBox, clear, limit, prefer);
}
}
interface ScoredTarget extends HexagonClickTarget {
score: number;
distanceFromCenter: number;
/** `avg_<field>` 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_<field>` 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 &&
@ -391,12 +415,53 @@ export class DashboardRecorder {
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);
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 {

View file

@ -553,13 +553,15 @@ export async function installCursor(
caption.id = '__demo-caption';
document.body.appendChild(caption);
window.addEventListener(
'mousemove',
(e) => {
const followPointer = (e: { clientX: number; clientY: number }) => {
cursor.style.transform = `translate(${e.clientX - hot}px, ${e.clientY - hot}px)`;
},
{ passive: true, capture: true }
);
};
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<void> {
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<HTMLElement>('*')).find((n) => {
Array.from(root.querySelectorAll<HTMLElement>('*')).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
);
}

View file

@ -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 }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };
// 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<number | null> => {
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: finalX, y: thumbCy };
}

View file

@ -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<void> {
}
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<void> {
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<void> {
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 };
}

View file

@ -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_<field>` 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>`. */
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[];

View file

@ -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)],
},
{

View file

@ -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