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 type { Page, Request, Response } from 'playwright';
import { waitForAnimationFrames } from './dom.js'; import { waitForAnimationFrames } from './dom.js';
import type { HexPreference } from './script.js';
interface Bounds { interface Bounds {
south: number; south: number;
@ -129,7 +130,10 @@ export class DashboardRecorder {
await this.waitForStable({ afterSelectionVersion, timeoutMs }); 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 // Empty responses don't replace the snapshot (parse* skips them), so a
// snapshot whose bounds differ from the latest REQUEST means the current // snapshot whose bounds differ from the latest REQUEST means the current
// view has zero matching features and we'd be projecting stale data. // 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; if (postcodeTargets.length > 0) return postcodeTargets;
const snapshot = this.lastHexagons; const snapshot = this.lastHexagons;
@ -175,32 +179,13 @@ export class DashboardRecorder {
y: point.y, y: point.y,
count: feature.count, count: feature.count,
score: feature.count / (1 + distanceFromCenter * 0.25), 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( return finishCandidates(projected, mapBox, clear, limit, prefer);
(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);
} }
/** /**
@ -347,7 +332,10 @@ export class DashboardRecorder {
return !loading && !connecting; return !loading && !connecting;
} }
private async visiblePostcodeTargets(limit: number): Promise<HexagonClickTarget[]> { private async visiblePostcodeTargets(
limit: number,
prefer?: HexPreference
): Promise<HexagonClickTarget[]> {
const snapshot = this.lastPostcodes; const snapshot = this.lastPostcodes;
if (!snapshot || snapshot.features.length === 0) return []; if (!snapshot || snapshot.features.length === 0) return [];
@ -373,32 +361,109 @@ export class DashboardRecorder {
y: point.y, y: point.y,
count: feature.properties.count, count: feature.properties.count,
score: feature.properties.count / (1 + distanceFromCenter * 0.25), 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( if (projected.length === 0) return [];
(target) => return finishCandidates(projected, mapBox, clear, limit, prefer);
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);
} }
} }
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 &&
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 { function classifyApiRequest(rawUrl: string): ApiKind | null {
let path: string; let path: string;
try { try {

View file

@ -553,13 +553,15 @@ export async function installCursor(
caption.id = '__demo-caption'; caption.id = '__demo-caption';
document.body.appendChild(caption); document.body.appendChild(caption);
window.addEventListener( const followPointer = (e: { clientX: number; clientY: number }) => {
'mousemove', cursor.style.transform = `translate(${e.clientX - hot}px, ${e.clientY - hot}px)`;
(e) => { };
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
{ passive: true, capture: true } // 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 & { (window as typeof window & {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void; __demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}).__demoMoveCursor = (x, y, durationMs) => { }).__demoMoveCursor = (x, y, durationMs) => {
@ -986,10 +988,17 @@ export async function scrollPaneTo(
selector: string, selector: string,
top: number top: number
): Promise<void> { ): Promise<void> {
await page.evaluate( // Resolve via a Playwright locator (not in-page querySelector) so
({ selector, top }) => { // storyboards can anchor on locator-only selectors like
const el = document.querySelector(selector) as HTMLElement | null; // `button:has-text("Schools")` to reach a specific scroll container.
if (!el) return; 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 => { const findScrollable = (node: HTMLElement | null): HTMLElement | null => {
let n: HTMLElement | null = node; let n: HTMLElement | null = node;
while (n) { while (n) {
@ -1001,15 +1010,16 @@ export async function scrollPaneTo(
}; };
// Look both inside (for the actual scroll container deeper in the tree) // Look both inside (for the actual scroll container deeper in the tree)
// and outwards. // and outwards.
const root = el as HTMLElement;
const inner = const inner =
Array.from(el.querySelectorAll<HTMLElement>('*')).find((n) => { Array.from(root.querySelectorAll<HTMLElement>('*')).find((n) => {
const oy = getComputedStyle(n).overflowY; const oy = getComputedStyle(n).overflowY;
return (oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight; return (oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight;
}) ?? null; }) ?? null;
const target = inner ?? findScrollable(el) ?? el; const target = inner ?? findScrollable(root) ?? root;
target.scrollTo({ top, behavior: 'smooth' }); target.scrollTo({ top, behavior: 'smooth' });
}, },
{ selector, top } top
); );
} }

View file

@ -131,7 +131,8 @@ export async function smoothDragSliderThumb(
trackSelector: string, trackSelector: string,
fromCursor: { x: number; y: number }, fromCursor: { x: number; y: number },
toFraction: number, toFraction: number,
durationMs = 520 durationMs = 520,
snapToValue?: number
): Promise<{ x: number; y: number }> { ): Promise<{ x: number; y: number }> {
const thumbBox = await page.locator(thumbSelector).boundingBox(); const thumbBox = await page.locator(thumbSelector).boundingBox();
const trackBox = await page.locator(trackSelector).boundingBox(); const trackBox = await page.locator(trackSelector).boundingBox();
@ -145,12 +146,45 @@ export async function smoothDragSliderThumb(
await page.mouse.down(); await page.mouse.down();
// The user explicitly prefers a longer render over stepped motion, so use // The user explicitly prefers a longer render over stepped motion, so use
// enough real pointer updates for the thumb itself to read as continuous. // enough real pointer updates for the thumb itself to read as continuous.
const dragMs = snapToValue == null ? durationMs : Math.max(200, durationMs - 400);
await smoothMove( await smoothMove(
page, page,
{ x: thumbCx, y: thumbCy }, { x: thumbCx, y: thumbCy },
{ x: targetX, 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<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(); await page.mouse.up();
return { x: targetX, y: thumbCy }; return { x: finalX, y: thumbCy };
} }

View file

@ -64,6 +64,15 @@ export async function runStoryboard(
narrationLog.reset(); narrationLog.reset();
const synth = loadSynthIndex(storyboard); 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 sceneStartMs = Date.now();
const leadInMs = LEAD_IN_S * 1000; const leadInMs = LEAD_IN_S * 1000;
const cursor = { ms: 0 }; const cursor = { ms: 0 };
@ -198,7 +207,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
} }
const candidates = const candidates =
step.target.kind === 'hexagon' && step.waitForSelectionReady step.target.kind === 'hexagon' && step.waitForSelectionReady
? await ctx.dashboard.visibleHexagonTargets(4) ? await ctx.dashboard.visibleHexagonTargets(4, step.target.prefer)
: [await resolveTarget(ctx, step.target)]; : [await resolveTarget(ctx, step.target)];
let lastError: unknown = null; 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); await fakeType(ctx.page, step.selector, step.text, step.durationMs);
return; return;
case 'mapZoom': { 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 point = await resolveTarget(ctx, step.target);
const mapVersion = ctx.dashboard.getMapDataVersion(); const mapVersion = ctx.dashboard.getMapDataVersion();
const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA; 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, step.trackSelector,
ctx.cursor, ctx.cursor,
step.toFraction, step.toFraction,
step.durationMs step.durationMs,
step.snapToValue
); );
if (step.waitForMapSettled) { if (step.waitForMapSettled) {
await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000); await ctx.dashboard.waitForMapSettled(mapVersion, step.timeoutMs ?? 12000);
@ -449,7 +466,7 @@ async function resolveTarget(
): Promise<{ x: number; y: number }> { ): Promise<{ x: number; y: number }> {
if (target.kind === 'point') return { x: target.x, y: target.y }; if (target.kind === 'point') return { x: target.x, y: target.y };
if (target.kind === 'hexagon') { 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'); if (targets.length === 0) throw new Error('No visible hexagon to target');
return { x: targets[0].x, y: targets[0].y }; return { x: targets[0].x, y: targets[0].y };
} }

View file

@ -75,6 +75,20 @@ export interface AdScene {
transparent?: boolean; 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. */ /** A point on screen, resolved at runtime to viewport pixels. */
export type Target = export type Target =
| { kind: 'point'; x: number; y: number } | { 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 * 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. * 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 * Resolved at runtime to (xFrac·viewport.width, yFrac·viewport.height). Use
* this when an activity needs a stable visual location that scales with the * 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 at = (x: number, y: number): Target => ({ kind: 'point', x, y });
export const el = (selector: string): Target => ({ kind: 'element', selector }); 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 => ({ export const vfrac = (xFrac: number, yFrac: number): Target => ({
kind: 'viewportFraction', kind: 'viewportFraction',
xFrac, xFrac,
@ -160,6 +174,13 @@ export type Activity =
trackSelector: string; trackSelector: string;
toFraction: number; toFraction: number;
durationMs: 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; waitForMapSettled?: boolean;
timeoutMs?: number; timeoutMs?: number;
} }
@ -350,6 +371,13 @@ export interface Storyboard {
video: VideoConfig; video: VideoConfig;
voice: VoiceConfig; voice: VoiceConfig;
content: ContentConfig; 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[]; pre?: Activity[];
cues: Cue[]; cues: Cue[];
post?: Activity[]; post?: Activity[];

View file

@ -1,4 +1,5 @@
import { import {
at,
el, el,
hex, hex,
vfrac, vfrac,
@ -679,6 +680,13 @@ interface DemoAdStoryboardConfig {
center?: { lat: number; lon: number }; center?: { lat: number; lon: number };
/** Plausible per-city "{{count}} properties match" total for the AI summary. */ /** Plausible per-city "{{count}} properties match" total for the AI summary. */
matchCount?: number; 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). */ /** Activities to run before the cue loop starts (silent, keep short). */
prePrime?: Activity[]; prePrime?: Activity[];
/** Spoken line during the outro card. Must NOT repeat the card's text. */ /** 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, waitForMapSettled: true,
timeoutMs, 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 => ({ const ttDragAct = (toMin: number, durationMs = 1700): Activity => ({
kind: 'dragSlider', kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`, thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`, trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: toMin / TT_SLIDER_MAX, 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, durationMs,
}); });
const wait = (durationMs: number): Activity => ({ kind: 'wait', 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 * of asserted. IfVisible + generous timeout so a missing card costs the
* colour, not the take; the runner scrolls the card into view first. * 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', kind: 'clickIfVisible',
target: el(`[data-filter-name="${featureName}"] button[title="Colour map"]`), target: el(`[data-filter-name="${featureName}"] button[title="Colour map"]`),
durationMs, durationMs,
timeoutMs: 2500, timeoutMs: 1200,
}); });
/** /**
* The final "the value is right here" zoom. Anchored on the strongest * The final "the value is right here" zoom. Anchored on a visible match
* visible match (hex()) and centered, so the camera always lands on * (hex()) and centered, so the camera always lands on populated, matching
* populated, matching postcodes. The old fixed-pixel zoom regularly dove * postcodes. The old fixed-pixel zoom regularly dove into parks or
* into parks or filtered-out suburbs and the payoff frame read as an * filtered-out suburbs and the payoff frame read as an empty, broken map.
* 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', kind: 'mapZoom',
target: hex(), target: hex(preferMinField ? { field: preferMinField, direction: 'min' } : undefined),
steps, steps,
durationMs, durationMs,
center: true, center: true,
@ -830,6 +858,19 @@ const scrollDrawer = (top: number, durationMs = 800): Activity => ({
top, top,
durationMs, 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. */ /** Default silent pre: vignette + hidden cursor; narration starts at t≈0. */
const AD_PRE: Activity[] = [ const AD_PRE: Activity[] = [
@ -871,6 +912,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
brand: AD_BRAND, brand: AD_BRAND,
matchCount: ad.matchCount, matchCount: ad.matchCount,
}, },
setup: ad.preStage,
pre: ad.prePrime ?? AD_PRE, pre: ad.prePrime ?? AD_PRE,
cues: [ cues: [
...ad.cues.map((cue, index) => ({ ...ad.cues.map((cue, index) => ({
@ -886,10 +928,12 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
text: ad.outroLine, text: ad.outroLine,
gapBeforeMs: 200, gapBeforeMs: 200,
during: [ during: [
// Let the closer start over the live product; the brand card pops // Hand the frame back to the live product for the first second of
// half a second in. A static card for the whole outro line plus a // the closer (fading any receipt overlay), then pop the brand
// 2.2s hold was 6+ seconds of dead frame, a third of the ad. // card. A static card for the whole outro line plus a 2.2s hold
wait(500), // was 6+ seconds of dead frame, a third of the ad.
{ kind: 'hideAdScene', durationMs: 200 },
wait(800),
{ {
kind: 'showOutro', kind: 'showOutro',
brand: AD_BRAND.name, brand: AD_BRAND.name,
@ -902,7 +946,7 @@ function createDemoAdStoryboard(ad: DemoAdStoryboardConfig): Storyboard {
}, },
], ],
// Just long enough to read the URL with sound off. // 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. // instead of the same orange map with a smaller count.
text: 'Every postcode in England that fits, sorted by value.', text: 'Every postcode in England that fits, sorted by value.',
caption: 'All of England, 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same schools, same commute, the price quietly drops nearby.', text: 'Same schools, same commute, the price quietly drops nearby.',
caption: 'The cheaper twin nearby', caption: 'The cheaper twin nearby',
during: [payoffZoom(1600, 4)], during: [payoffZoom(1600, 4, 'Estimated current price')],
tail: [wait(300)], tail: [wait(300)],
}, },
], ],
@ -978,18 +1022,25 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
initialZoom: 10.2, initialZoom: 10.2,
posterTimeS: 9, posterTimeS: 9,
outroLine: 'The commute is priced in. The bargain is not.', outroLine: 'The commute is priced in. The bargain is not.',
prePrime: [ // Cold-open ON the rainbow: the colour toggle, the settle, and the
{ kind: 'clearVignette', durationMs: 0 }, // 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 }, { kind: 'cursorScale', scale: 0, durationMs: 0 },
// Paint the map by journey time, then hand the frame to the map.
{ {
kind: 'click', kind: 'click',
target: el(`${TT_CARD_SELECTOR} button[title="Colour map"]`), target: el(`${TT_CARD_SELECTOR} button[title="Colour map"]`),
durationMs: 600, durationMs: 600,
}, },
{ kind: 'wait', durationMs: 500 }, { kind: 'wait', durationMs: 900 },
sheetDown(700), 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: [ cues: [
{ {
@ -1002,7 +1053,7 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
text: "Here's what twenty minutes actually leaves you.", text: "Here's what twenty minutes actually leaves you.",
caption: 'What 20 minutes buys', caption: 'What 20 minutes buys',
during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)], 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.", text: "Same twenty minutes wherever it's lit, but not the same price.",
@ -1028,11 +1079,17 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
initialZoom: 10.6, initialZoom: 10.6,
posterTimeS: 13, posterTimeS: 13,
outroLine: 'Every postcode, proven on the numbers, not its reputation.', outroLine: 'Every postcode, proven on the numbers, not its reputation.',
prePrime: [ // Sheet collapse is staging, not story: do it before the trim anchor so
{ kind: 'clearVignette', durationMs: 0 }, // the ad cold-opens on the full-bleed filtered map.
preStage: [
{ kind: 'cursorScale', scale: 0, durationMs: 0 }, { kind: 'cursorScale', scale: 0, durationMs: 0 },
sheetDown(700), 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: [ cues: [
{ {
@ -1055,28 +1112,30 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
tail: [wait(150)], tail: [wait(150)],
}, },
{ {
// Breathe after the tap: the drawer needs a beat to render Street // The Street View iframe needs ~2s to paint after the drawer opens,
// View + the price chart. Scrolling during that window put a blank // and its empty white/black canvas read as the product breaking.
// white pane on screen, which 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.', text: 'Sold prices, schools, crime, even Street View, for this exact postcode.',
caption: 'The whole postcode file', caption: 'The whole postcode file',
during: [touchShow(), tapHex(1100), wait(600), scrollDrawer(420, 800)], during: [touchShow(), tapHex(1100), scrollDrawerDeep(1150, 500)],
tail: [wait(200)], tail: [wait(200)],
}, },
{ {
// Open a real section instead of scrolling a wall of collapsed // Scroll back up to the (now-loaded) Street View photo, then expand
// headers: expanded school data IS the "evidence" the line claims. // the Schools section: real data on screen for the "evidence" line.
text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.', text: 'The same evidence a pricey postcode has, sitting quietly cheaper here.',
caption: 'Same proof, less money', caption: 'Same proof, less money',
during: [ during: [
scrollDrawerDeep(760, 600),
wait(500),
{ {
kind: 'clickIfVisible', kind: 'clickIfVisible',
target: el(`${RIGHT_PANE_SELECTOR} button:has-text("Schools")`), target: el(`${RIGHT_PANE_SELECTOR} button:has-text("Schools")`),
durationMs: 700, durationMs: 650,
timeoutMs: 1500, timeoutMs: 1500,
}, },
wait(300), scrollDrawerDeep(1450, 800),
scrollDrawer(700, 900),
touchHide(), touchHide(),
], ],
tail: [wait(300)], tail: [wait(300)],
@ -1111,13 +1170,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// gets a visual instead of a subtly thinner orange map. // gets a visual instead of a subtly thinner orange map.
text: 'This is London under fifty-five decibels.', text: 'This is London under fifty-five decibels.',
caption: 'Under 55 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Nearby, just as quiet, and overlooked.', text: 'Nearby, just as quiet, and overlooked.',
caption: 'Quiet, and overlooked', caption: 'Quiet, and overlooked',
during: [payoffZoom(1700, 4)], during: [payoffZoom(1700, 4, 'Noise (dB)')],
tail: [wait(300)], tail: [wait(300)],
}, },
], ],
@ -1153,13 +1212,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// pockets visible inside the school-filtered map. // pockets visible inside the school-filtered map.
text: 'Everything still on the map passes all three filters.', text: 'Everything still on the map passes all three filters.',
caption: 'Passes all three', 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same primary, same low crime, without the name everyone else is bidding up.', text: 'Same primary, same low crime, without the name everyone else is bidding up.',
caption: 'Same catchment, less money', caption: 'Same catchment, less money',
during: [payoffZoom(1700, 4)], during: [payoffZoom(1700, 4, 'Estimated current price')],
tail: [wait(300)], tail: [wait(300)],
}, },
], ],
@ -1195,16 +1254,19 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
text: 'London, narrowed to the postcodes that fit the way you live.', text: 'London, narrowed to the postcodes that fit the way you live.',
caption: 'Fits how you live', caption: 'Fits how you live',
during: [ during: [
submitSettled(1400), submitAct(600),
colourMapAct('Distance to nearest amenity (Waitrose) (km)'), // Amenity-distance filters render inside the folded "Amenity
wait(300), // 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same amenities, lower price nearby.', text: 'Same amenities, lower price nearby.',
caption: 'Same life, lower price', caption: 'Same life, lower price',
during: [payoffZoom(1500, 4)], during: [payoffZoom(1500, 4, 'Distance to nearest amenity (Waitrose) (km)')],
tail: [wait(300)], tail: [wait(300)],
}, },
], ],
@ -1217,9 +1279,13 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
name: 'ad-07-renters-map', name: 'ad-07-renters-map',
matchCount: 2160, matchCount: 2160,
city: 'london', 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: { 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], 'Noise (dB)': [0, 58],
}, },
travelTimeFilters: [ travelTimeFilters: [
@ -1230,9 +1296,9 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
outroLine: 'The name costs more. The street does not.', outroLine: 'The name costs more. The street does not.',
cues: [ 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', 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)], tail: [wait(150)],
}, },
{ {
@ -1240,13 +1306,27 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
// line promises. // line promises.
text: 'Letting sites rank flats. This ranks the streets around them.', text: 'Letting sites rank flats. This ranks the streets around them.',
caption: 'Areas, not just flats', 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Same commute, same quiet, lower rent nearby.', text: 'Same commute, same quiet, lower rent nearby.',
caption: 'Same street, lower rent', 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)], 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.", text: "You pay for the postcode's name; the value sits a postcode away.",
caption: 'You pay for the name', 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {
text: 'Pay once, find the bargain, stop overpaying for the name.', text: 'Pay once, find the bargain, stop overpaying for the name.',
caption: 'Find the value instead', caption: 'Find the value instead',
during: [payoffZoom(1600, 4)], during: [payoffZoom(1600, 4, 'Estimated current price')],
tail: [wait(300)], 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.", savingSpoken: "That's more than a hundred thousand pounds back on an average home.",
distMeta: '2.6 km apart', distMeta: '2.6 km apart',
hook: 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 }, center: { lat: 51.57199, lon: -0.3079 },
initialZoom: 12.0, initialZoom: 12.0,
matchCount: 1620, 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.', text: 'Now colour every postcode by what a square metre of home actually costs there.',
caption: 'Coloured by £ per m²', 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)], tail: [sheetDown(700), wait(200)],
}, },
{ {

View file

@ -32,7 +32,10 @@ export async function prepareTimeline(
await installCursor(page, storyboard.video.cursorStyle ?? 'arrow'); await installCursor(page, storyboard.video.cursorStyle ?? 'arrow');
await setAspectClass(page, storyboard.video.aspect); 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); await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
// Only pre-open the AI prompt when the storyboard actually types into it. // Only pre-open the AI prompt when the storyboard actually types into it.
// Opening it unconditionally grows the mobile bottom sheet (keyboard // Opening it unconditionally grows the mobile bottom sheet (keyboard