Improve videos

This commit is contained in:
Andras Schmelczer 2026-06-10 21:28:19 +01:00
parent 4012e4e047
commit d3418c67cc
11 changed files with 988 additions and 869 deletions

View file

@ -34,8 +34,11 @@ export async function launchRecordingBrowser(
'--enable-gpu-rasterization',
'--enable-zero-copy',
'--disable-software-rasterizer',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
// NOTE: --disable-frame-rate-limit / --disable-gpu-vsync used to be
// here for screencast smoothness, but with the host GPU nearly full
// the uncapped render loop starved the renderer (ERR_INSUFFICIENT_
// RESOURCES + ~31s page stalls on the 1080p take). Vsync-limited
// 60fps is plenty for the 50fps output.
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',

View file

@ -66,10 +66,19 @@ export class DashboardRecorder {
private selectionStatsVersion = 0;
private lastHexagons: HexagonSnapshot | null = null;
private lastPostcodes: PostcodeSnapshot | null = null;
private lastRequestedMapBounds: string | null = null;
constructor(private readonly page: Page) {
page.on('request', (request) => {
if (classifyApiRequest(request.url())) this.pending.add(request);
const kind = classifyApiRequest(request.url());
if (kind) this.pending.add(request);
if (kind === 'hexagons' || kind === 'postcodes') {
try {
this.lastRequestedMapBounds = new URL(request.url()).searchParams.get('bounds');
} catch {
/* ignore */
}
}
});
page.on('requestfinished', (request) => this.pending.delete(request));
page.on('requestfailed', (request) => this.pending.delete(request));
@ -90,6 +99,28 @@ export class DashboardRecorder {
await this.waitForStable({ afterMapVersion, timeoutMs });
}
/**
* Best-effort wait for tracked API traffic to go quiet (250ms of no
* pending requests and no loading indicator). Unlike waitForStable this
* never throws it simply returns at the deadline. Used before computing
* hexagon click targets so the projection runs against the response for
* the CURRENT viewport rather than one captured mid-animation.
*/
async waitForApiIdle(timeoutMs = 3000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let stableSince: number | null = null;
while (Date.now() < deadline) {
const idle = this.pending.size === 0 && (await this.loadingIndicatorsHidden());
if (idle) {
stableSince ??= Date.now();
if (Date.now() - stableSince >= 250) return;
} else {
stableSince = null;
}
await this.page.waitForTimeout(100);
}
}
async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise<void> {
await this.page
.locator(SELECTION_PANE_SELECTOR)
@ -99,6 +130,22 @@ export class DashboardRecorder {
}
async visibleHexagonTargets(limit = 8): 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.
// Surface that loudly — it almost always means the storyboard's filters
// emptied the area it zoomed into.
const snapshotBounds = this.lastPostcodes?.bounds ?? this.lastHexagons?.bounds;
if (snapshotBounds && this.lastRequestedMapBounds) {
const snapKey = `${snapshotBounds.south},${snapshotBounds.west},${snapshotBounds.north},${snapshotBounds.east}`;
if (snapKey !== this.lastRequestedMapBounds) {
console.log(
`[dashboard] WARNING: map snapshot is stale (snapshot bounds ${snapKey} ` +
`vs latest request ${this.lastRequestedMapBounds}) — the current view ` +
`likely has no matching features; clicks may land on empty map.`
);
}
}
const postcodeTargets = await this.visiblePostcodeTargets(limit);
if (postcodeTargets.length > 0) return postcodeTargets;
@ -188,10 +235,14 @@ export class DashboardRecorder {
height: number;
}): Promise<{ top: number; bottom: number; left: number; right: number }> {
let bottomClear = mapBox.y + mapBox.height - 115;
// Short timeout: on desktop the MobileBottomSheet doesn't exist, and a
// bare boundingBox() would block for Playwright's default 30s before
// the catch fires (this masqueraded as a "renderer freeze" in every
// desktop take that used hex() targets).
const sheet = await this.page
.locator('section[class*="rounded-t-2xl"]')
.first()
.boundingBox()
.boundingBox({ timeout: 250 })
.catch(() => null);
if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) {
bottomClear = sheet.y - 16;

View file

@ -9,7 +9,10 @@ import type { AdScene, AdScenePanel } from './script.js';
* the Node side. That keeps a single source of truth Playwright's real mouse
* and the visual is pure CSS, animated by the browser's compositor.
*/
export async function installCursor(page: Page): Promise<void> {
export async function installCursor(
page: Page,
style: 'arrow' | 'touch' = 'arrow'
): Promise<void> {
await page.addStyleTag({
content: `
*, *::before, *::after { cursor: none !important; }
@ -30,6 +33,12 @@ export async function installCursor(page: Page): Promise<void> {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
}
#__demo-cursor.click { scale: 0.85; }
/* Touch mode: a soft fingertip dot centred on the contact point. */
#__demo-cursor.touch {
width: 34px; height: 34px;
transform-origin: 17px 17px;
}
#__demo-cursor.touch.click { scale: 0.7; }
.__demo-ripple {
position: fixed;
@ -72,49 +81,54 @@ export async function installCursor(page: Page): Promise<void> {
#__demo-vignette.gone { opacity: 0; }
/*
* Caption positioning rules of thumb:
* Caption = short HOOK CHIP, not a transcript. Cues carry an optional
* 6-word caption that complements the narration (the spoken line is
* never rendered). Because the text is short, the chip can be loud
* (big type, accent bar) without covering the product.
*
* Positioning rules of thumb:
* Vertical (9:16) cuts MUST keep the caption inside the top ~62% of
* the viewport. TikTok, Reels, and Shorts overlay their own chrome
* across the bottom ~30%, so anything below y=68% gets eaten by
* the platform UI. Mobile dashboard captures also have a sheet
* covering the bottom half, so a low caption sits over filter
* controls rather than over the map.
* Horizontal (16:9) cuts can use the classic lower-third instead.
* Horizontal (16:9) cuts use a compact lower-third chip instead.
* The body class is set once at recorder setup (setAspectClass) so
* every cue inherits the right positioning.
*/
#__demo-caption {
position: fixed;
left: 50%;
transform: translate(-50%, 28px);
transform: translate(-50%, 24px);
width: max-content;
max-width: min(1160px, 86vw);
padding: 22px 30px;
border-radius: 22px;
background: rgba(2, 6, 23, 0.92);
backdrop-filter: blur(20px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1);
max-width: min(900px, 88vw);
padding: 14px 22px;
border-radius: 14px;
background: rgba(2, 6, 23, 0.9);
backdrop-filter: blur(16px) saturate(1.1);
-webkit-backdrop-filter: blur(16px) saturate(1.1);
color: #ffffff;
font:
800 36px/1.22 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
850 34px/1.15 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
sans-serif;
letter-spacing: -0.005em;
letter-spacing: -0.01em;
text-align: center;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.55),
inset 0 0 0 1.5px rgba(255, 255, 255, 0.16);
0 16px 44px rgba(0, 0, 0, 0.5),
inset 0 0 0 1.5px rgba(45, 212, 191, 0.45);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition:
opacity 280ms ease-out,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
opacity 240ms ease-out,
transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: normal;
}
/* Horizontal default: classic lower-third. */
/* Horizontal: compact lower-third chip. */
body.__demo-aspect-horizontal #__demo-caption {
bottom: 7%;
bottom: 6%;
}
body.__demo-aspect-horizontal #__demo-caption.placement-side {
left: auto;
@ -122,21 +136,22 @@ export async function installCursor(page: Page): Promise<void> {
bottom: 10%;
transform: translate(28px, 0);
max-width: min(560px, 30vw);
padding: 18px 22px;
border-radius: 18px;
padding: 14px 20px;
border-radius: 14px;
font-size: 26px;
line-height: 1.18;
text-align: left;
}
/* Vertical default: upper-third. Kept compact so the map remains the
primary visual in the social ad cuts. */
/* Vertical: upper area, clear of platform chrome and the bottom sheet.
At the 540-wide CSS viewport this renders ~30px type 60px in the
published 1080-wide mp4: a proper social-video hook size. */
body.__demo-aspect-vertical #__demo-caption {
top: 7%;
max-width: min(820px, 82vw);
font-size: 27px;
font-weight: 750;
top: 8%;
max-width: 88vw;
font-size: 30px;
font-weight: 850;
padding: 12px 18px;
border-radius: 14px;
border-radius: 12px;
}
#__demo-caption.visible {
opacity: 1;
@ -496,14 +511,26 @@ export async function installCursor(page: Page): Promise<void> {
`,
});
await page.evaluate(() => {
await page.evaluate((style) => {
const cursor = document.createElement('div');
cursor.id = '__demo-cursor';
cursor.innerHTML = `
<svg viewBox="0 0 22 22" width="22" height="22" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2 L2 18 L7 13 L10 20 L13 19 L10 12 L17 12 Z"
fill="white" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/>
</svg>`;
// Hotspot: arrow tip sits 2px in from the SVG corner; the touch dot is
// centred on the contact point.
const hot = style === 'touch' ? 17 : 2;
if (style === 'touch') {
cursor.classList.add('touch');
cursor.innerHTML = `
<svg viewBox="0 0 34 34" width="34" height="34" xmlns="http://www.w3.org/2000/svg">
<circle cx="17" cy="17" r="14" fill="rgba(255,255,255,0.38)"
stroke="rgba(255,255,255,0.95)" stroke-width="2.5"/>
</svg>`;
} else {
cursor.innerHTML = `
<svg viewBox="0 0 22 22" width="22" height="22" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2 L2 18 L7 13 L10 20 L13 19 L10 12 L17 12 Z"
fill="white" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/>
</svg>`;
}
document.body.appendChild(cursor);
const vignette = document.createElement('div');
@ -517,7 +544,7 @@ export async function installCursor(page: Page): Promise<void> {
window.addEventListener(
'mousemove',
(e) => {
cursor.style.transform = `translate(${e.clientX - 2}px, ${e.clientY - 2}px)`;
cursor.style.transform = `translate(${e.clientX - hot}px, ${e.clientY - hot}px)`;
},
{ passive: true, capture: true }
);
@ -525,7 +552,7 @@ export async function installCursor(page: Page): Promise<void> {
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
}).__demoMoveCursor = (x, y, durationMs) => {
cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`;
cursor.style.transform = `translate(${x - 2}px, ${y - 2}px)`;
cursor.style.transform = `translate(${x - hot}px, ${y - hot}px)`;
window.setTimeout(() => {
cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out';
}, durationMs + 40);
@ -549,7 +576,7 @@ export async function installCursor(page: Page): Promise<void> {
() => cursor.classList.remove('click'),
{ passive: true, capture: true }
);
});
}, style);
}
export async function clearVignette(page: Page): Promise<void> {
@ -897,8 +924,15 @@ export async function zoomTo(
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs);
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
// Clamp the pan so the scaled app always covers the whole viewport.
// Without this, focusing an element near a screen edge drags the app
// off-frame and exposes the dark backdrop (the old cold-open showed a
// third of the frame as void). For scale ≥ 1 the translation must stay
// within [size·(1scale), 0] on each axis.
const clampPan = (value: number, span: number): number =>
scale >= 1 ? Math.min(0, Math.max(span * (1 - scale), value)) : value;
const dx = clampPan(viewport.width / 2 - scale * focusX, viewport.width);
const dy = clampPan(viewport.height / 2 - scale * focusY, viewport.height);
await page.evaluate(
({ dx, dy, scale, transitionMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap');

View file

@ -59,9 +59,79 @@ function emitScript(storyboard: Storyboard): string {
return path;
}
function main(): void {
/**
* Validate every stubbed/initial filter name and travel-destination slug
* against the LIVE API. Wrong names don't error in the app they silently
* no-op, the map never changes, and you only find out after a full render.
* Fails hard on a mismatch; soft-warns if the API is unreachable (render.sh
* has already health-checked it by the time preflight runs).
*/
async function validateAgainstLiveApi(): Promise<void> {
const apiBase = process.env.API_URL ?? process.env.APP_URL;
if (!apiBase) {
console.warn('[preflight] no API_URL/APP_URL set — skipping live filter validation');
return;
}
let featureNames: Set<string>;
try {
const res = await fetch(`${apiBase}/api/features`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as {
groups: { features: { name: string }[] }[];
};
featureNames = new Set(body.groups.flatMap((g) => g.features.map((f) => f.name)));
} catch (err) {
console.warn(`[preflight] could not fetch ${apiBase}/api/features (${err}) — skipping validation`);
return;
}
const problems: string[] = [];
for (const sb of storyboards) {
const filterNames = [
...Object.keys(sb.content.stubbedFilters),
...Object.keys(sb.content.initialFilters ?? {}),
];
for (const name of filterNames) {
if (!featureNames.has(name)) {
problems.push(`[${sb.name}] filter "${name}" is not a live /api/features name`);
}
}
}
const modes = new Set(
storyboards.flatMap((sb) => sb.content.stubbedTravelTimeFilters.map((tt) => tt.mode))
);
for (const mode of modes) {
try {
const res = await fetch(`${apiBase}/api/travel-destinations?mode=${mode}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { destinations: { slug: string }[] };
const slugs = new Set(body.destinations.map((d) => d.slug));
for (const sb of storyboards) {
for (const tt of sb.content.stubbedTravelTimeFilters) {
if (tt.mode === mode && !slugs.has(tt.slug)) {
problems.push(`[${sb.name}] travel destination "${tt.slug}" (${mode}) not on live API`);
}
}
}
} catch (err) {
console.warn(`[preflight] could not validate travel destinations for ${mode}: ${err}`);
}
}
if (problems.length > 0) {
for (const p of problems) console.error(`[preflight] FAIL: ${p}`);
process.exit(1);
}
console.log('[preflight] all stubbed filter names and travel slugs match the live API');
}
async function main(): Promise<void> {
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
await validateAgainstLiveApi();
for (const sb of storyboards) emitScript(sb);
// Index for shell loops — each entry has every field render.sh needs to
@ -84,4 +154,7 @@ function main(): void {
console.log(`[preflight] wrote storyboard index → ${indexPath}`);
}
main();
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -51,6 +51,31 @@ async function recordOne(storyboard: Storyboard): Promise<void> {
const u = r.url();
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`);
});
// Debug instrumentation: per-second request-rate histogram by path bucket.
// The desktop take intermittently freezes for ~30s after heavy filter
// churn; this shows whether a request flood is what's choking the page.
if (process.env.VIDEO_DEBUG_REQ_RATE === '1') {
const counts = new Map<string, number>();
page.on('request', (r) => {
let bucket: string;
try {
const u = new URL(r.url());
bucket = u.pathname.split('/').slice(0, 3).join('/');
} catch {
bucket = 'other';
}
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
});
const timer = setInterval(() => {
if (counts.size === 0) return;
const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
console.log(
`[req-rate] ${top.map(([k, v]) => `${k}=${v}`).join(' ')}`
);
counts.clear();
}, 1000);
timer.unref();
}
await installDemoRoutes(page, storyboard);
const ctx = await prepareTimeline(page, storyboard);

View file

@ -13,6 +13,15 @@ export function dashboardUrl(storyboard: Storyboard): string {
lon: String(view.lon),
zoom: String(view.zoom),
});
// Cold-open filters: applied through the URL so the first frame already
// shows a filtered map. Numeric features use name:min:max, enums name:a|b.
for (const [name, value] of Object.entries(storyboard.content.initialFilters ?? {})) {
const entry =
typeof value[0] === 'number'
? `${name}:${value[0]}:${value[1]}`
: `${name}:${(value as string[]).join('|')}`;
params.append('filter', entry);
}
for (const tt of storyboard.content.stubbedTravelTimeFilters) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
}

View file

@ -101,7 +101,10 @@ async function runCue(
videoTimeMs: cursor.ms + leadInMs,
durationMs: measuredAudioMs,
});
await showCaption(ctx.page, cue.text, cue.captionPlacement);
// The spoken line is never rendered; only an explicit short caption is.
if (cue.caption) {
await showCaption(ctx.page, cue.caption, cue.captionPlacement);
}
const during = cue.during ?? [];
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
@ -126,7 +129,9 @@ async function runCue(
cursor.ms += duringElapsed;
}
await hideCaption(ctx.page);
if (cue.caption) {
await hideCaption(ctx.page);
}
for (const step of cue.tail ?? []) {
cursor.ms += await runStep(ctx, step);
@ -185,6 +190,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
}
case 'click': {
const selectionVersion = ctx.dashboard.getSelectionStatsVersion();
// Hexagon targets are projected from the latest map response; make
// sure that response corresponds to the settled viewport (a zoom in
// the previous cue may still have postcode fetches in flight).
if (step.target.kind === 'hexagon') {
await ctx.dashboard.waitForApiIdle(3000);
}
const candidates =
step.target.kind === 'hexagon' && step.waitForSelectionReady
? await ctx.dashboard.visibleHexagonTargets(4)
@ -228,7 +239,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
const mapVersion = ctx.dashboard.getMapDataVersion();
const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA;
const handled = await ctx.page.evaluate(
async ({ x, y, steps, durationMs, direction }) => {
async ({ x, y, steps, durationMs, direction, center }) => {
const root = document.querySelector('.maplibregl-map') as HTMLElement | null;
const fiberKey = root
? Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactFiber$'))
@ -264,6 +275,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
zoom: number,
options: { around?: unknown; duration?: number; essential?: boolean }
) => void;
easeTo?: (options: {
center?: unknown;
zoom?: number;
duration?: number;
essential?: boolean;
}) => void;
};
if (
typeof mapApi.getCanvas !== 'function' ||
@ -281,7 +298,16 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
const minZoom = mapApi.getMinZoom?.() ?? 0;
const maxZoom = mapApi.getMaxZoom?.() ?? 22;
const targetZoom = Math.max(minZoom, Math.min(maxZoom, mapApi.getZoom() + zoomDelta));
mapApi.zoomTo(targetZoom, { around, duration: durationMs, essential: true });
if (center && typeof mapApi.easeTo === 'function') {
mapApi.easeTo({
center: around,
zoom: targetZoom,
duration: durationMs,
essential: true,
});
} else {
mapApi.zoomTo(targetZoom, { around, duration: durationMs, essential: true });
}
await new Promise((resolve) => window.setTimeout(resolve, durationMs));
return true;
},
@ -291,6 +317,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
steps: step.steps,
durationMs: step.durationMs,
direction: step.direction,
center: step.center ?? false,
}
);
@ -363,6 +390,41 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
case 'scrollPane':
await scrollPaneTo(ctx.page, step.selector, step.top);
return;
case 'dragSheet': {
const sheet = ctx.page.locator('section[class*="rounded-t-2xl"]').first();
const sheetBox = await sheet.boundingBox().catch(() => null);
if (!sheetBox) return; // desktop layout — nothing to drag
const handle = ctx.page
.locator('section[class*="rounded-t-2xl"] [class*="touch-none"]')
.first();
const handleBox = (await handle.boundingBox().catch(() => null)) ?? {
x: sheetBox.x,
y: sheetBox.y + 4,
width: sheetBox.width,
height: 20,
};
const viewport = ctx.page.viewportSize() ?? { width: 540, height: 960 };
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
// The sheet resizes so its top tracks the pointer (the component
// clamps to its own min/max). Aim the pointer where the handle would
// sit when the sheet covers `toHeightFrac` of the viewport.
const handleOffset = startY - sheetBox.y;
const endY = viewport.height * (1 - step.toHeightFrac) + handleOffset;
const approachMs = Math.min(260, Math.max(140, Math.round(step.durationMs * 0.25)));
const dragMs = Math.max(180, step.durationMs - approachMs - 80);
await smoothMove(ctx.page, ctx.cursor, { x: startX, y: startY }, { durationMs: approachMs });
await ctx.page.mouse.down();
await smoothMove(
ctx.page,
{ x: startX, y: startY },
{ x: startX, y: endY },
{ durationMs: dragMs, realMouse: true }
);
await ctx.page.mouse.up();
ctx.cursor = { x: startX, y: endY };
return;
}
case 'openFilterGroup':
// Click is idempotent: if the group is already expanded, the click
// would collapse it — which we don't want. Detect via aria-expanded
@ -375,6 +437,9 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
trigger.click();
}, step.selector);
return;
case 'pressKey':
await ctx.page.keyboard.press(step.key);
return;
}
}
@ -395,7 +460,9 @@ async function resolveTarget(
y: Math.round(Math.min(1, Math.max(0, target.yFrac)) * viewport.height),
};
}
const box = await ctx.page.locator(target.selector).boundingBox();
// Bounded wait: a storyboard pointing at a missing element should fail in
// seconds with a clear error, not stall for Playwright's default 30s.
const box = await ctx.page.locator(target.selector).boundingBox({ timeout: 5000 });
if (!box) throw new Error(`No bounding box for selector: ${target.selector}`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}

View file

@ -143,6 +143,13 @@ export type Activity =
steps: number;
durationMs: number;
direction?: 'in' | 'out';
/**
* When true, the camera eases so `target` ends up at the canvas
* centre (instead of staying pinned at its current screen position).
* Use for "zoom into the best match" shots so the payoff lands
* centre-frame rather than wherever the match happened to be.
*/
center?: boolean;
waitForMapSettled?: boolean;
timeoutMs?: number;
}
@ -178,28 +185,45 @@ export type Activity =
* scroll through the property-stats drawer after a postcode click.
*/
| { kind: 'scrollPane'; selector: string; top: number; durationMs: number }
/**
* Drag the MobileBottomSheet's grab handle so the sheet ends up covering
* `toHeightFrac` of the viewport height. The component clamps to its own
* min/max, so 0 collapses the sheet to its grab-handle sliver and the map
* becomes the hero of the frame. No-op when the sheet isn't in the DOM
* (desktop layouts).
*/
| { kind: 'dragSheet'; toHeightFrac: number; durationMs: number }
/**
* Click the header of a collapsible filter group (e.g. "Transport",
* "Schools") so the cards beneath it become visible. Idempotent
* if the group is already open this is a no-op click.
*/
| { kind: 'openFilterGroup'; selector: string; durationMs: number };
| { kind: 'openFilterGroup'; selector: string; durationMs: number }
/** Press a keyboard key (e.g. 'Escape' to dismiss a modal). */
| { kind: 'pressKey'; key: string; durationMs: number };
/**
* A narration cue + the activities that play alongside it.
*
* gapBeforeMs : silent wall-time before the caption appears (= silence in
* text : the SPOKEN narration line (TTS input). Never rendered on
* screen what's said must not also be shown.
* caption : optional SHORT on-screen chip (6 words) complementing the
* narration. Distinct from `text` by design: visual hooks
* ("You can't hear a photo") and stats live here, the story
* lives in the voice. Omit for no on-screen text.
* gapBeforeMs : silent wall-time before the cue starts (= silence in
* audio between the previous cue ending and this one).
* during : activities that play WHILE the caption is on screen. The
* during : activities that play WHILE the cue's audio runs. The
* sum of declared durations must be the measured audio
* duration; the runner pads short blocks so the caption stays
* on for the full cue. Sum > measured is a hard error.
* tail : activities that run AFTER the caption hides, before the
* next cue's gapBefore starts. Use it for dwells/transitions
* that aren't tied to spoken words.
* duration; the runner pads short blocks so the cue lasts
* the full audio. Sum > measured is a hard error.
* tail : activities that run AFTER the cue's audio (and caption)
* end, before the next cue's gapBefore starts. Use it for
* dwells/transitions that aren't tied to spoken words.
*/
export interface Cue {
text: string;
caption?: string;
/** Optional cue-specific caption layout for shots where the default lower-third hides the product. */
captionPlacement?: 'side';
gapBeforeMs: number;
@ -223,6 +247,14 @@ export interface VideoConfig {
* If unset, the viewport comes from `viewportFor(aspect)`.
*/
viewport?: { width: number; height: number };
/**
* Visual style of the injected cursor. 'arrow' (default) renders the
* classic pointer right for desktop demos. 'touch' renders a soft
* fingertip dot, which reads as a phone gesture on 9:16 mobile cuts
* (an arrow cursor on a phone-shaped video instantly breaks the
* illusion that you're watching the mobile product).
*/
cursorStyle?: 'arrow' | 'touch';
/** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */
webmBitrate: string;
/** Final fps after the trim/resample pass. */
@ -273,6 +305,12 @@ export interface ContentConfig {
/** Cold-open zoom multiplier on the AI card. */
aiZoomScale: number;
initialMapView: { lat: number; lon: number; zoom: number };
/**
* Filters applied via the dashboard URL before the recording starts, so a
* storyboard can cold-open on an already-filtered map without burning
* screen time on a type+submit. Keys must be real /api/features names.
*/
initialFilters?: Record<string, [number, number] | string[]>;
stubbedFilters: Record<string, [number, number] | string[]>;
stubbedTravelTimeFilters: TravelTimeFilter[];
travelTimeCardSelector: string;

File diff suppressed because it is too large Load diff

View file

@ -29,16 +29,30 @@ export async function prepareTimeline(
await sleep(400);
await installZoomWrapper(page);
await installCursor(page);
await installCursor(page, storyboard.video.cursorStyle ?? 'arrow');
await setAspectClass(page, storyboard.video.aspect);
const ctx: ScriptCtx = { page, dashboard, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await prepareAiBox(ctx);
// Only pre-open the AI prompt when the storyboard actually types into it.
// Opening it unconditionally grows the mobile bottom sheet (keyboard
// avoidance) and steals the frame from the map on ads that never type.
if (storyboardTypes(storyboard)) {
await prepareAiBox(ctx);
}
await sleep(80);
return ctx;
}
function storyboardTypes(storyboard: Storyboard): boolean {
const all = [
...(storyboard.pre ?? []),
...storyboard.cues.flatMap((cue) => [...(cue.during ?? []), ...(cue.tail ?? [])]),
...(storyboard.post ?? []),
];
return all.some((activity) => activity.kind === 'type');
}
export async function runTimeline(
ctx: ScriptCtx,
storyboard: Storyboard