Improve vide
This commit is contained in:
parent
e544b946c9
commit
716e42a57d
7 changed files with 358 additions and 121 deletions
|
|
@ -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,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_<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 {
|
||||
let path: string;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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: targetX, y: thumbCy };
|
||||
return { x: finalX, y: thumbCy };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue