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

@ -337,19 +337,20 @@ if [ "$DO_AUDIO" = "1" ]; then
# it across the rest of the storyboards by copying _reference.wav + # it across the rest of the storyboards by copying _reference.wav +
# _reference.meta.json into their audio dirs before their synth runs. # _reference.meta.json into their audio dirs before their synth runs.
# synth.py's _resolve_reference() reuses a matching cached reference # synth.py's _resolve_reference() reuses a matching cached reference
# as long as the meta block (instruct/language/seed/etc.) matches — # as long as the meta block (instruct/language/seed/etc.) matches.
# which it always does, because every ad shares AD_VOICE. #
# We copy ONLY the reference, never the cue wavs or index.json. Copying
# the whole audio dir (as an earlier version did) overwrote each later
# storyboard's cached index.json with the FIRST storyboard's, which
# forced a full re-synth on every run — and in multi-voice sets (the
# localized homepage demos: en/de/zh/hi) it clobbered correct localized
# audio. With a reference-only copy: same-voice sets reuse the reference
# (meta matches); different-voice sets re-mint their own (meta mismatch),
# and in both cases an up-to-date cached index.json lets synth skip.
shared_ref_wav="" shared_ref_wav=""
shared_ref_meta="" shared_ref_meta=""
shared_audio_dir=""
for sb in "${STORYBOARDS[@]}"; do for sb in "${STORYBOARDS[@]}"; do
if [ -n "$shared_audio_dir" ] && [ -d "$shared_audio_dir" ]; then if [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
mkdir -p "output/$sb/audio"
for cached_audio_file in "$shared_audio_dir"/*.wav "$shared_audio_dir"/*.json; do
[ -f "$cached_audio_file" ] || continue
cp -f "$cached_audio_file" "output/$sb/audio/$(basename "$cached_audio_file")"
done
elif [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
mkdir -p "output/$sb/audio" mkdir -p "output/$sb/audio"
cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav" cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav"
cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json" cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json"
@ -365,9 +366,6 @@ if [ "$DO_AUDIO" = "1" ]; then
shared_ref_meta="output/$sb/audio/_reference.meta.json" shared_ref_meta="output/$sb/audio/_reference.meta.json"
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set" say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
fi fi
if [ -z "$shared_audio_dir" ] && [ -s "output/$sb/audio/index.json" ]; then
shared_audio_dir="output/$sb/audio"
fi
done done
fi fi

View file

@ -34,8 +34,11 @@ export async function launchRecordingBrowser(
'--enable-gpu-rasterization', '--enable-gpu-rasterization',
'--enable-zero-copy', '--enable-zero-copy',
'--disable-software-rasterizer', '--disable-software-rasterizer',
'--disable-frame-rate-limit', // NOTE: --disable-frame-rate-limit / --disable-gpu-vsync used to be
'--disable-gpu-vsync', // 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-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',

View file

@ -66,10 +66,19 @@ export class DashboardRecorder {
private selectionStatsVersion = 0; private selectionStatsVersion = 0;
private lastHexagons: HexagonSnapshot | null = null; private lastHexagons: HexagonSnapshot | null = null;
private lastPostcodes: PostcodeSnapshot | null = null; private lastPostcodes: PostcodeSnapshot | null = null;
private lastRequestedMapBounds: string | null = null;
constructor(private readonly page: Page) { constructor(private readonly page: Page) {
page.on('request', (request) => { 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('requestfinished', (request) => this.pending.delete(request));
page.on('requestfailed', (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 }); 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> { async waitForSelectionReady(afterSelectionVersion: number, timeoutMs = 12000): Promise<void> {
await this.page await this.page
.locator(SELECTION_PANE_SELECTOR) .locator(SELECTION_PANE_SELECTOR)
@ -99,6 +130,22 @@ export class DashboardRecorder {
} }
async visibleHexagonTargets(limit = 8): Promise<HexagonClickTarget[]> { 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); const postcodeTargets = await this.visiblePostcodeTargets(limit);
if (postcodeTargets.length > 0) return postcodeTargets; if (postcodeTargets.length > 0) return postcodeTargets;
@ -188,10 +235,14 @@ export class DashboardRecorder {
height: number; height: number;
}): Promise<{ top: number; bottom: number; left: number; right: number }> { }): Promise<{ top: number; bottom: number; left: number; right: number }> {
let bottomClear = mapBox.y + mapBox.height - 115; 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 const sheet = await this.page
.locator('section[class*="rounded-t-2xl"]') .locator('section[class*="rounded-t-2xl"]')
.first() .first()
.boundingBox() .boundingBox({ timeout: 250 })
.catch(() => null); .catch(() => null);
if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) { if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) {
bottomClear = sheet.y - 16; 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 * 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. * 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({ await page.addStyleTag({
content: ` content: `
*, *::before, *::after { cursor: none !important; } *, *::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)); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
} }
#__demo-cursor.click { scale: 0.85; } #__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 { .__demo-ripple {
position: fixed; position: fixed;
@ -72,49 +81,54 @@ export async function installCursor(page: Page): Promise<void> {
#__demo-vignette.gone { opacity: 0; } #__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 * Vertical (9:16) cuts MUST keep the caption inside the top ~62% of
* the viewport. TikTok, Reels, and Shorts overlay their own chrome * the viewport. TikTok, Reels, and Shorts overlay their own chrome
* across the bottom ~30%, so anything below y=68% gets eaten by * across the bottom ~30%, so anything below y=68% gets eaten by
* the platform UI. Mobile dashboard captures also have a sheet * the platform UI. Mobile dashboard captures also have a sheet
* covering the bottom half, so a low caption sits over filter * covering the bottom half, so a low caption sits over filter
* controls rather than over the map. * 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 * The body class is set once at recorder setup (setAspectClass) so
* every cue inherits the right positioning. * every cue inherits the right positioning.
*/ */
#__demo-caption { #__demo-caption {
position: fixed; position: fixed;
left: 50%; left: 50%;
transform: translate(-50%, 28px); transform: translate(-50%, 24px);
width: max-content; width: max-content;
max-width: min(1160px, 86vw); max-width: min(900px, 88vw);
padding: 22px 30px; padding: 14px 22px;
border-radius: 22px; border-radius: 14px;
background: rgba(2, 6, 23, 0.92); background: rgba(2, 6, 23, 0.9);
backdrop-filter: blur(20px) saturate(1.1); backdrop-filter: blur(16px) saturate(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.1); -webkit-backdrop-filter: blur(16px) saturate(1.1);
color: #ffffff; color: #ffffff;
font: 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; sans-serif;
letter-spacing: -0.005em; letter-spacing: -0.01em;
text-align: center; text-align: center;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55); text-shadow: 0 2px 6px rgba(0, 0, 0, 0.55);
box-shadow: box-shadow:
0 22px 60px rgba(0, 0, 0, 0.55), 0 16px 44px rgba(0, 0, 0, 0.5),
inset 0 0 0 1.5px rgba(255, 255, 255, 0.16); inset 0 0 0 1.5px rgba(45, 212, 191, 0.45);
z-index: 2147483641; z-index: 2147483641;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: transition:
opacity 280ms ease-out, opacity 240ms ease-out,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1); transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: normal; white-space: normal;
} }
/* Horizontal default: classic lower-third. */ /* Horizontal: compact lower-third chip. */
body.__demo-aspect-horizontal #__demo-caption { body.__demo-aspect-horizontal #__demo-caption {
bottom: 7%; bottom: 6%;
} }
body.__demo-aspect-horizontal #__demo-caption.placement-side { body.__demo-aspect-horizontal #__demo-caption.placement-side {
left: auto; left: auto;
@ -122,21 +136,22 @@ export async function installCursor(page: Page): Promise<void> {
bottom: 10%; bottom: 10%;
transform: translate(28px, 0); transform: translate(28px, 0);
max-width: min(560px, 30vw); max-width: min(560px, 30vw);
padding: 18px 22px; padding: 14px 20px;
border-radius: 18px; border-radius: 14px;
font-size: 26px; font-size: 26px;
line-height: 1.18; line-height: 1.18;
text-align: left; text-align: left;
} }
/* Vertical default: upper-third. Kept compact so the map remains the /* Vertical: upper area, clear of platform chrome and the bottom sheet.
primary visual in the social ad cuts. */ 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 { body.__demo-aspect-vertical #__demo-caption {
top: 7%; top: 8%;
max-width: min(820px, 82vw); max-width: 88vw;
font-size: 27px; font-size: 30px;
font-weight: 750; font-weight: 850;
padding: 12px 18px; padding: 12px 18px;
border-radius: 14px; border-radius: 12px;
} }
#__demo-caption.visible { #__demo-caption.visible {
opacity: 1; 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'); const cursor = document.createElement('div');
cursor.id = '__demo-cursor'; cursor.id = '__demo-cursor';
cursor.innerHTML = ` // Hotspot: arrow tip sits 2px in from the SVG corner; the touch dot is
<svg viewBox="0 0 22 22" width="22" height="22" xmlns="http://www.w3.org/2000/svg"> // centred on the contact point.
<path d="M2 2 L2 18 L7 13 L10 20 L13 19 L10 12 L17 12 Z" const hot = style === 'touch' ? 17 : 2;
fill="white" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/> if (style === 'touch') {
</svg>`; 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); document.body.appendChild(cursor);
const vignette = document.createElement('div'); const vignette = document.createElement('div');
@ -517,7 +544,7 @@ export async function installCursor(page: Page): Promise<void> {
window.addEventListener( window.addEventListener(
'mousemove', 'mousemove',
(e) => { (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 } { 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: number, y: number, durationMs: number) => void;
}).__demoMoveCursor = (x, y, durationMs) => { }).__demoMoveCursor = (x, y, durationMs) => {
cursor.style.transition = `transform ${durationMs}ms cubic-bezier(0.22, 1, 0.36, 1), scale 120ms ease-out`; 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(() => { window.setTimeout(() => {
cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out'; cursor.style.transition = 'transform 60ms linear, scale 120ms ease-out';
}, durationMs + 40); }, durationMs + 40);
@ -549,7 +576,7 @@ export async function installCursor(page: Page): Promise<void> {
() => cursor.classList.remove('click'), () => cursor.classList.remove('click'),
{ passive: true, capture: true } { passive: true, capture: true }
); );
}); }, style);
} }
export async function clearVignette(page: Page): Promise<void> { export async function clearVignette(page: Page): Promise<void> {
@ -897,8 +924,15 @@ export async function zoomTo(
const { scale, focusX, focusY, durationMs = 1100 } = opts; const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs); const transitionMs = Math.round(durationMs);
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 }; const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX; // Clamp the pan so the scaled app always covers the whole viewport.
const dy = viewport.height / 2 - scale * focusY; // 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( await page.evaluate(
({ dx, dy, scale, transitionMs }) => { ({ dx, dy, scale, transitionMs }) => {
const wrap = document.getElementById('__demo-zoom-wrap'); const wrap = document.getElementById('__demo-zoom-wrap');

View file

@ -59,9 +59,79 @@ function emitScript(storyboard: Storyboard): string {
return path; 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 }); if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
await validateAgainstLiveApi();
for (const sb of storyboards) emitScript(sb); for (const sb of storyboards) emitScript(sb);
// Index for shell loops — each entry has every field render.sh needs to // 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}`); 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(); const u = r.url();
if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`); 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); await installDemoRoutes(page, storyboard);
const ctx = await prepareTimeline(page, storyboard); const ctx = await prepareTimeline(page, storyboard);

View file

@ -13,6 +13,15 @@ export function dashboardUrl(storyboard: Storyboard): string {
lon: String(view.lon), lon: String(view.lon),
zoom: String(view.zoom), 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) { for (const tt of storyboard.content.stubbedTravelTimeFilters) {
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`); 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, videoTimeMs: cursor.ms + leadInMs,
durationMs: measuredAudioMs, 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 during = cue.during ?? [];
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0); const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
@ -126,7 +129,9 @@ async function runCue(
cursor.ms += duringElapsed; cursor.ms += duringElapsed;
} }
await hideCaption(ctx.page); if (cue.caption) {
await hideCaption(ctx.page);
}
for (const step of cue.tail ?? []) { for (const step of cue.tail ?? []) {
cursor.ms += await runStep(ctx, step); cursor.ms += await runStep(ctx, step);
@ -185,6 +190,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
} }
case 'click': { case 'click': {
const selectionVersion = ctx.dashboard.getSelectionStatsVersion(); 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 = const candidates =
step.target.kind === 'hexagon' && step.waitForSelectionReady step.target.kind === 'hexagon' && step.waitForSelectionReady
? await ctx.dashboard.visibleHexagonTargets(4) ? await ctx.dashboard.visibleHexagonTargets(4)
@ -228,7 +239,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
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;
const handled = await ctx.page.evaluate( 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 root = document.querySelector('.maplibregl-map') as HTMLElement | null;
const fiberKey = root const fiberKey = root
? Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactFiber$')) ? Object.getOwnPropertyNames(root).find((key) => key.startsWith('__reactFiber$'))
@ -264,6 +275,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
zoom: number, zoom: number,
options: { around?: unknown; duration?: number; essential?: boolean } options: { around?: unknown; duration?: number; essential?: boolean }
) => void; ) => void;
easeTo?: (options: {
center?: unknown;
zoom?: number;
duration?: number;
essential?: boolean;
}) => void;
}; };
if ( if (
typeof mapApi.getCanvas !== 'function' || typeof mapApi.getCanvas !== 'function' ||
@ -281,7 +298,16 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
const minZoom = mapApi.getMinZoom?.() ?? 0; const minZoom = mapApi.getMinZoom?.() ?? 0;
const maxZoom = mapApi.getMaxZoom?.() ?? 22; const maxZoom = mapApi.getMaxZoom?.() ?? 22;
const targetZoom = Math.max(minZoom, Math.min(maxZoom, mapApi.getZoom() + zoomDelta)); 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)); await new Promise((resolve) => window.setTimeout(resolve, durationMs));
return true; return true;
}, },
@ -291,6 +317,7 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
steps: step.steps, steps: step.steps,
durationMs: step.durationMs, durationMs: step.durationMs,
direction: step.direction, direction: step.direction,
center: step.center ?? false,
} }
); );
@ -363,6 +390,41 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
case 'scrollPane': case 'scrollPane':
await scrollPaneTo(ctx.page, step.selector, step.top); await scrollPaneTo(ctx.page, step.selector, step.top);
return; 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': case 'openFilterGroup':
// Click is idempotent: if the group is already expanded, the click // Click is idempotent: if the group is already expanded, the click
// would collapse it — which we don't want. Detect via aria-expanded // 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(); trigger.click();
}, step.selector); }, step.selector);
return; 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), 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}`); 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 }; return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
} }

View file

@ -143,6 +143,13 @@ export type Activity =
steps: number; steps: number;
durationMs: number; durationMs: number;
direction?: 'in' | 'out'; 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; waitForMapSettled?: boolean;
timeoutMs?: number; timeoutMs?: number;
} }
@ -178,28 +185,45 @@ export type Activity =
* scroll through the property-stats drawer after a postcode click. * scroll through the property-stats drawer after a postcode click.
*/ */
| { kind: 'scrollPane'; selector: string; top: number; durationMs: number } | { 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", * Click the header of a collapsible filter group (e.g. "Transport",
* "Schools") so the cards beneath it become visible. Idempotent * "Schools") so the cards beneath it become visible. Idempotent
* if the group is already open this is a no-op click. * 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. * 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). * 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 * sum of declared durations must be the measured audio
* duration; the runner pads short blocks so the caption stays * duration; the runner pads short blocks so the cue lasts
* on for the full cue. Sum > measured is a hard error. * the full audio. Sum > measured is a hard error.
* tail : activities that run AFTER the caption hides, before the * tail : activities that run AFTER the cue's audio (and caption)
* next cue's gapBefore starts. Use it for dwells/transitions * end, before the next cue's gapBefore starts. Use it for
* that aren't tied to spoken words. * dwells/transitions that aren't tied to spoken words.
*/ */
export interface Cue { export interface Cue {
text: string; text: string;
caption?: string;
/** Optional cue-specific caption layout for shots where the default lower-third hides the product. */ /** Optional cue-specific caption layout for shots where the default lower-third hides the product. */
captionPlacement?: 'side'; captionPlacement?: 'side';
gapBeforeMs: number; gapBeforeMs: number;
@ -223,6 +247,14 @@ export interface VideoConfig {
* If unset, the viewport comes from `viewportFor(aspect)`. * If unset, the viewport comes from `viewportFor(aspect)`.
*/ */
viewport?: { width: number; height: number }; 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". */ /** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */
webmBitrate: string; webmBitrate: string;
/** Final fps after the trim/resample pass. */ /** Final fps after the trim/resample pass. */
@ -273,6 +305,12 @@ export interface ContentConfig {
/** Cold-open zoom multiplier on the AI card. */ /** Cold-open zoom multiplier on the AI card. */
aiZoomScale: number; aiZoomScale: number;
initialMapView: { lat: number; lon: number; zoom: 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[]>; stubbedFilters: Record<string, [number, number] | string[]>;
stubbedTravelTimeFilters: TravelTimeFilter[]; stubbedTravelTimeFilters: TravelTimeFilter[];
travelTimeCardSelector: string; 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 sleep(400);
await installZoomWrapper(page); await installZoomWrapper(page);
await installCursor(page); 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 } }; const ctx: ScriptCtx = { page, dashboard, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y); 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); await sleep(80);
return ctx; 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( export async function runTimeline(
ctx: ScriptCtx, ctx: ScriptCtx,
storyboard: Storyboard storyboard: Storyboard