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