Improve videos
This commit is contained in:
parent
4012e4e047
commit
d3418c67cc
11 changed files with 988 additions and 869 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
110
video/src/dom.ts
110
video/src/dom.ts
|
|
@ -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·(1−scale), 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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue