This commit is contained in:
Andras Schmelczer 2026-05-14 08:09:19 +01:00
parent a8165249a4
commit a4103b0896
64 changed files with 5376 additions and 3832 deletions

View file

@ -231,6 +231,19 @@ poster_time_for() {
' "$1"
}
# Resolve the FINAL published video dimensions for a storyboard. The
# recording happens at the CSS viewport, but the encode pass upscales to
# `captureScale x viewport` via lanczos so the published mp4 is true
# 1080x1920 on mobile rather than a soft 540x960. Returns "WxH".
published_size_for() {
node -e '
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
const sb = idx.storyboards.find(s => s.name === process.argv[1]);
if (!sb || !sb.publishedSize) { process.exit(1); }
process.stdout.write(`${sb.publishedSize.width}x${sb.publishedSize.height}`);
' "$1"
}
# -- per-storyboard wipe of leaking artefacts --------------------------------
# output/<sb>/audio/ is preserved; tts/synth.py decides whether the cached
# WAVs still match the script and skips generation when they do. In resume
@ -273,13 +286,36 @@ if [ "$DO_AUDIO" = "1" ]; then
say "Synchronising tts/ Python deps"
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
# Voice consistency: every ad in this set declares the same AD_VOICE
# (instruct/seed/temperature/topP/referenceText). Even with seed-locked
# VoiceDesign, independent invocations across processes can produce
# mildly different reference waveforms — different enough that a
# listener notices the timbre shift across ads. To avoid that, we
# mint the reference WAV ONCE (from the first storyboard) and reuse
# it across the rest of the storyboards by copying _reference.wav +
# _reference.meta.json into their audio dirs before their synth runs.
# synth.py's _resolve_reference() reuses a matching cached reference
# as long as the meta block (instruct/language/seed/etc.) matches —
# which it always does, because every ad shares AD_VOICE.
shared_ref_wav=""
shared_ref_meta=""
for sb in "${STORYBOARDS[@]}"; do
say "Synthesising narration for [$sb] — one batched call"
if [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
mkdir -p "output/$sb/audio"
cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav"
cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json"
fi
say "Synthesising narration for [$sb]"
uv run --project tts python tts/synth.py --storyboard "$sb" \
|| fail "tts/synth.py failed for $sb"
if [ ! -s "output/$sb/audio/index.json" ]; then
fail "synth did not produce output/$sb/audio/index.json"
fi
if [ -z "$shared_ref_wav" ] && [ -f "output/$sb/audio/_reference.wav" ]; then
shared_ref_wav="output/$sb/audio/_reference.wav"
shared_ref_meta="output/$sb/audio/_reference.meta.json"
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
fi
done
fi
@ -305,7 +341,16 @@ fi
for sb in "${STORYBOARDS[@]}"; do
if [ "$DO_ENCODE" = "1" ]; then
say "[$sb] Encoding to MP4"
# Lanczos upscale the recording to its published dimensions
# (captureScale × viewport). For captureScale=1 the filter is a
# no-op and ffmpeg copies the size through; for captureScale=2
# mobile cuts go 540x960 → 1080x1920 sharply because Chromium
# already rasterised internally at DPR=2.
pub_size="$(published_size_for "$sb")"
pub_w="${pub_size%x*}"
pub_h="${pub_size#*x}"
ffmpeg -y -loglevel warning -i "output/$sb/recording.webm" \
-vf "scale=${pub_w}:${pub_h}:flags=lanczos" \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
"output/$sb/recording.mp4"

View file

@ -103,7 +103,7 @@ export class DashboardRecorder {
throw new Error('No recorded hexagon response is available for map clicking');
}
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
const mapBox = await this.mapBoundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
const clear = await this.clickableBox(mapBox);
@ -152,6 +152,23 @@ export class DashboardRecorder {
return candidates.slice(0, limit).map(({ score: _score, ...target }) => target);
}
/**
* Locate the map container's bounding box. DesktopMapPage tags it with
* `[data-tutorial="map"]`, but MobileMapPage does not, so on mobile we
* fall back to the maplibre-gl canvas element. The canvas is always
* present once the map has loaded (we already wait for `canvas` in
* prepareTimeline). The first canvas in the document IS the map.
*/
private async mapBoundingBox(): Promise<
{ x: number; y: number; width: number; height: number } | null
> {
const desktopAnchor = this.page.locator('[data-tutorial="map"]').first();
if ((await desktopAnchor.count()) > 0) {
return desktopAnchor.boundingBox();
}
return this.page.locator('canvas').first().boundingBox();
}
/**
* The pixel rect inside `mapBox` that's safe to click i.e. not under
* the dashboard's left filters pane, right details pane, or (on mobile)
@ -279,7 +296,7 @@ export class DashboardRecorder {
const snapshot = this.lastPostcodes;
if (!snapshot || snapshot.features.length === 0) return [];
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
const mapBox = await this.mapBoundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
const clear = await this.clickableBox(mapBox);

View file

@ -1,5 +1,5 @@
import type { Page } from 'playwright';
import type { AdScene } from './script.js';
import type { AdScene, AdScenePanel } from './script.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
@ -71,30 +71,67 @@ export async function installCursor(page: Page): Promise<void> {
}
#__demo-vignette.gone { opacity: 0; }
/*
* Caption 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.
* The body class is set once at recorder setup (setAspectClass) so
* every cue inherits the right positioning.
*/
#__demo-caption {
position: fixed;
left: 50%;
bottom: 6.5%;
transform: translate(-50%, 28px);
width: max-content;
max-width: min(1160px, 78vw);
padding: 18px 28px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.84);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: #f0fdfa;
font: 650 32px/1.25 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0;
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);
color: #ffffff;
font:
800 36px/1.22 "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
sans-serif;
letter-spacing: -0.005em;
text-align: center;
box-shadow: 0 18px 54px rgba(0,0,0,0.38), inset 0 0 0 1px rgba(255,255,255,0.1);
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);
z-index: 2147483641;
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease-out, transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
transition:
opacity 280ms ease-out,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
white-space: normal;
}
#__demo-caption.visible { opacity: 1; transform: translate(-50%, 0); }
/* Horizontal default: classic lower-third. */
body.__demo-aspect-horizontal #__demo-caption {
bottom: 7%;
}
/* Vertical default: upper-third. Sized DOWN deliberately the
caption must not occlude the map below it. ~30px on a 540-wide
viewport sits at ~5.6% of viewport width per character; a single
line typically wraps in 2 rows of large bold text. */
body.__demo-aspect-vertical #__demo-caption {
top: 9%;
max-width: min(880px, 84vw);
font-size: 30px;
font-weight: 750;
padding: 14px 20px;
border-radius: 16px;
}
#__demo-caption.visible {
opacity: 1;
transform: translate(-50%, 0);
}
#__demo-outro {
position: fixed; inset: 0;
@ -128,32 +165,42 @@ export async function installCursor(page: Page): Promise<void> {
100% { opacity: 1; transform: translateY(0) scale(1); }
}
#__demo-outro-brand {
font: 760 72px/1.05 ui-sans-serif, system-ui, sans-serif;
margin: 0 0 16px;
font: 850 72px/1.05 "Inter", ui-sans-serif, system-ui, sans-serif;
margin: 0 0 18px;
background: linear-gradient(90deg, #5eead4, #14b8a6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: 0;
letter-spacing: -0.015em;
}
#__demo-outro-tagline {
font: 500 28px/1.4 ui-sans-serif, system-ui, sans-serif;
color: #cbd5e1;
margin: 0 0 28px;
font: 600 28px/1.36 "Inter", ui-sans-serif, system-ui, sans-serif;
color: #e2e8f0;
margin: 0 0 30px;
max-width: 28ch;
margin-left: auto;
margin-right: auto;
}
#__demo-outro-url {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 16px 24px;
padding: 18px 26px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(94, 234, 212, 0.36);
box-shadow: 0 22px 60px rgba(0,0,0,0.32);
font: 700 34px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0;
color: #99f6e4;
background: rgba(2, 6, 23, 0.92);
border: 1.5px solid rgba(45, 212, 191, 0.6);
box-shadow:
0 22px 60px rgba(0, 0, 0, 0.5),
0 0 36px rgba(45, 212, 191, 0.18);
font: 800 36px/1 "Inter", ui-sans-serif, system-ui, sans-serif;
letter-spacing: -0.01em;
color: #5eead4;
}
/* Tighter outro for vertical 9:16 the brand/url stack must fit
comfortably inside the platform-safe centre column. */
body.__demo-aspect-vertical #__demo-outro-brand { font-size: 64px; }
body.__demo-aspect-vertical #__demo-outro-tagline { font-size: 26px; max-width: 22ch; }
body.__demo-aspect-vertical #__demo-outro-url { font-size: 30px; padding: 16px 22px; }
.__ad-scene {
position: fixed;
@ -177,21 +224,39 @@ export async function installCursor(page: Page): Promise<void> {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(2, 6, 23, 0.86) 0%, rgba(2, 6, 23, 0.46) 44%, rgba(2, 6, 23, 0.88) 100%),
linear-gradient(180deg, rgba(2, 6, 23, 0.9) 0%, rgba(2, 6, 23, 0.66) 50%, rgba(2, 6, 23, 0.94) 100%),
linear-gradient(135deg, var(--ad-accent-soft), transparent 38%, rgba(15, 23, 42, 0.38));
backdrop-filter: blur(2px) saturate(0.92);
-webkit-backdrop-filter: blur(2px) saturate(0.92);
backdrop-filter: blur(3px) saturate(0.92);
-webkit-backdrop-filter: blur(3px) saturate(0.92);
}
/*
* Transparent mode: no scrim, no blur the product stays fully
* visible behind a floating kicker + title. Used by mid-cue hook
* stings ("Postcode polygraph") that should not occlude the demo.
*/
.__ad-scene.transparent .__ad-scrim { display: none; }
.__ad-scene.transparent .__ad-title,
.__ad-scene.transparent .__ad-body {
text-shadow:
0 3px 12px rgba(0, 0, 0, 0.75),
0 1px 3px rgba(0, 0, 0, 0.9);
}
.__ad-scene.transparent .__ad-frame {
background: linear-gradient(180deg, rgba(2, 6, 23, 0.78), rgba(2, 6, 23, 0.18) 60%, transparent);
padding-bottom: 60px;
bottom: auto;
height: 60%;
}
.__ad-frame {
position: absolute;
top: 118px;
left: 58px;
right: 58px;
bottom: 330px;
top: 6%;
left: 5%;
right: 5%;
bottom: 22%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 24px;
gap: 22px;
}
.__ad-kicker {
align-self: flex-start;
@ -215,16 +280,58 @@ export async function installCursor(page: Page): Promise<void> {
margin: 0;
max-width: 940px;
color: #fff;
font: 850 82px/1.02 ui-sans-serif, system-ui, sans-serif;
font: 850 64px/1.04 "Inter", ui-sans-serif, system-ui, sans-serif;
letter-spacing: -0.012em;
text-wrap: balance;
}
.__ad-body {
max-width: 890px;
margin: 0;
color: #dbeafe;
font: 560 38px/1.24 ui-sans-serif, system-ui, sans-serif;
color: #e2e8f0;
font: 580 32px/1.26 "Inter", ui-sans-serif, system-ui, sans-serif;
text-wrap: balance;
}
.__ad-image {
width: 100%;
max-width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 18px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
border: 1.5px solid rgba(255, 255, 255, 0.1);
}
.__ad-image-caption {
margin-top: -6px;
color: #cbd5e1;
font: 600 22px/1.3 "Inter", ui-sans-serif, system-ui, sans-serif;
text-align: center;
}
.__ad-image-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.__ad-image-split .__ad-image {
aspect-ratio: 3 / 4;
}
.__ad-image-split-meta {
position: absolute;
left: 8px;
bottom: 8px;
right: 8px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(2, 6, 23, 0.86);
color: #f8fafc;
font: 700 22px/1.15 "Inter", ui-sans-serif, system-ui, sans-serif;
text-align: center;
}
.__ad-image-split-cell {
position: relative;
}
.__ad-image-split-cell.good .__ad-image-split-meta { color: #86efac; }
.__ad-image-split-cell.warn .__ad-image-split-meta { color: #fde68a; }
.__ad-image-split-cell.bad .__ad-image-split-meta { color: #fda4af; }
.__ad-split {
display: grid;
grid-template-columns: 1fr;
@ -323,7 +430,24 @@ export async function installCursor(page: Page): Promise<void> {
justify-content: center;
}
.__ad-scene.mode-title .__ad-title {
font-size: 94px;
font-size: 78px;
}
/*
* Mobile (9x16) renders the dashboard at CSS 540x960 with captureScale 2.
* That means the AdScene CSS is sizing against a 540px-wide viewport, so
* a 64px title is ~12% of viewport width per line still big and bold,
* but no longer overflows the available space the way 82px did on the
* old 1080-wide ad config. Captions also re-anchor to the upper third
* via body.__demo-aspect-vertical.
*/
body.__demo-aspect-vertical .__ad-scene .__ad-title {
font-size: 58px;
}
body.__demo-aspect-vertical .__ad-scene.mode-title .__ad-title {
font-size: 68px;
}
body.__demo-aspect-vertical .__ad-scene .__ad-body {
font-size: 28px;
}
.__ad-scene.mode-comment .__ad-comment {
margin-bottom: 12px;
@ -421,6 +545,28 @@ export async function clearVignette(page: Page): Promise<void> {
});
}
/**
* Tag the document body with the aspect class the caption / overlay CSS keys
* off. Run once during recorder setup so every cue inherits the right
* positioning without per-call overrides. The body class is the cheapest
* stable signal the storyboard's `video.aspect` knows the truth and we
* surface it once into the DOM.
*/
export async function setAspectClass(
page: Page,
aspect: '16x9' | '9x16'
): Promise<void> {
await page.evaluate((aspect) => {
document.body.classList.remove(
'__demo-aspect-horizontal',
'__demo-aspect-vertical'
);
document.body.classList.add(
aspect === '9x16' ? '__demo-aspect-vertical' : '__demo-aspect-horizontal'
);
}, aspect);
}
export async function showCaption(page: Page, text: string): Promise<void> {
await page.evaluate((t) => {
const el = document.getElementById('__demo-caption');
@ -514,7 +660,9 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
const root =
document.getElementById('__ad-scene') ?? document.createElement('div');
root.id = '__ad-scene';
root.className = `__ad-scene mode-${mode} accent-${accent}`;
root.className =
`__ad-scene mode-${mode} accent-${accent}` +
(s.transparent ? ' transparent' : '');
root.replaceChildren();
const scrim = make('div', '__ad-scrim');
@ -522,10 +670,54 @@ export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
if (s.comment) frame.appendChild(make('div', '__ad-comment', s.comment));
if (s.kicker) frame.appendChild(make('div', '__ad-kicker', s.kicker));
// Side-by-side photos (used by ads like "two streets apart"). When the
// ad has split images, render them in place of the panel grid: the
// photos themselves are the comparison, not just text labels.
if (s.images) {
const split = make('div', '__ad-image-split');
const [leftSrc, rightSrc] = s.images;
const buildCell = (src: string, panel?: AdScenePanel): HTMLElement => {
const cell = make('div', `__ad-image-split-cell ${panel?.tone ?? 'neutral'}`);
const img = document.createElement('img');
img.className = '__ad-image';
// Skip crossOrigin so the request goes through as a vanilla
// image fetch and CORS headers on the Unsplash CDN are not
// required. We never read pixels back out — display only.
img.referrerPolicy = 'no-referrer';
img.onerror = () => img.remove();
img.src = src;
cell.appendChild(img);
if (panel?.title || panel?.meta) {
const meta = make(
'div',
'__ad-image-split-meta',
[panel?.title, panel?.meta].filter(Boolean).join(' · ')
);
cell.appendChild(meta);
}
return cell;
};
split.appendChild(buildCell(leftSrc, s.left));
split.appendChild(buildCell(rightSrc, s.right));
frame.appendChild(split);
} else if (s.image) {
const img = document.createElement('img');
img.className = '__ad-image';
img.referrerPolicy = 'no-referrer';
img.onerror = () => img.remove();
img.src = s.image;
frame.appendChild(img);
if (s.imageCaption) {
frame.appendChild(make('div', '__ad-image-caption', s.imageCaption));
}
}
frame.appendChild(make('h1', '__ad-title', s.title));
if (s.body) frame.appendChild(make('p', '__ad-body', s.body));
if (s.left || s.right) {
// Text-only side panels are only used when there are no photos.
if (!s.images && (s.left || s.right)) {
const split = make('div', '__ad-split');
for (const panel of [s.left, s.right]) {
if (!panel) continue;
@ -595,7 +787,8 @@ export async function showOutro(
taglineEl.textContent = tagline;
const urlEl = document.createElement('div');
urlEl.id = '__demo-outro-url';
urlEl.textContent = url;
// Drop the protocol so the CTA reads as a bare domain.
urlEl.textContent = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
card.append(brandEl, taglineEl, urlEl);
el.appendChild(card);
document.body.appendChild(el);

View file

@ -1,7 +1,7 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { OUTPUT_DIR } from './config.js';
import type { Storyboard } from './script.js';
import { publishedSizeFor, type Storyboard } from './script.js';
import { storyboards } from './storyboard.js';
/**
@ -75,6 +75,8 @@ function main(): void {
minDurationS: sb.video.minDurationS,
maxDurationS: sb.video.maxDurationS,
posterTimeS: sb.video.posterTimeS,
// Final mp4 dimensions after render.sh's lanczos upscale.
publishedSize: publishedSizeFor(sb.video),
})),
};
const indexPath = join(OUTPUT_DIR, 'storyboards.json');

View file

@ -6,6 +6,7 @@ import {
clearVignette,
hideAdScene,
hideCaption,
scrollPaneTo,
setCursorScale,
showAdScene,
showCaption,
@ -228,6 +229,21 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
case 'hideAdScene':
await hideAdScene(ctx.page);
return;
case 'scrollPane':
await scrollPaneTo(ctx.page, step.selector, step.top);
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
// (Radix Accordion sets it on the trigger) and skip the click when
// the group is already open.
await ctx.page.evaluate((selector) => {
const trigger = document.querySelector<HTMLElement>(selector);
if (!trigger) return;
if (trigger.getAttribute('aria-expanded') === 'true') return;
trigger.click();
}, step.selector);
return;
}
}

View file

@ -60,6 +60,19 @@ export interface AdScene {
left?: AdScenePanel;
right?: AdScenePanel;
items?: AdSceneItem[];
/** Optional single hero photo (URL) shown above the title. */
image?: string;
/** Optional [left, right] photos for split mode — used by "two streets apart" style ads. */
images?: [string, string];
/** Optional caption shown under the image for attribution / context. */
imageCaption?: string;
/**
* If true, render the scene with a transparent background so the dashboard
* stays visible behind. Useful for hooks where you want a floating kicker
* + title without occluding the live product. Defaults to false (full
* scrim, used by the closing "title" cards).
*/
transparent?: boolean;
}
/** A point on screen, resolved at runtime to viewport pixels. */
@ -140,7 +153,19 @@ export type Activity =
/** Fade the ad overlay away. */
| { kind: 'hideAdScene'; durationMs: number }
/** Fade away the opening vignette. */
| { kind: 'clearVignette'; durationMs: number };
| { kind: 'clearVignette'; durationMs: number }
/**
* Smoothly scroll the closest scrollable ancestor of `selector` to
* absolute pixel `top`. Used to surface a specific filter card or to
* scroll through the property-stats drawer after a postcode click.
*/
| { kind: 'scrollPane'; selector: string; top: number; durationMs: number }
/**
* Click the header of a collapsible filter group (e.g. "Transport",
* "Education") 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 };
/**
* A narration cue + the activities that play alongside it.
@ -281,12 +306,27 @@ export function viewportFor(video: VideoConfig): { width: number; height: number
}
/**
* Recorded video resolution. Equal to the CSS viewport because
* Playwright's recordVideo writes frames at CSS pixel size regardless
* of `deviceScaleFactor`. Kept as a separate function so future
* supersample + post-encode flows (e.g. ffmpeg lanczos upscale) can
* plug in here without touching verify.ts.
* Recorded video resolution. Equal to the CSS viewport.
*
* Playwright's recordVideo captures the page at its CSS-pixel surface, so
* passing a size larger than the viewport just letterboxes the content
* into the top-left of an empty frame not a true high-DPR raster.
* Final-resolution upscale (e.g. mobile 540x960 1080x1920) is done in
* render.sh's ffmpeg pass with `scale=...:flags=lanczos`, which gives a
* sharp upscale because Chromium rasterises internally at DPR=captureScale.
*/
export function recordedSizeFor(video: VideoConfig): { width: number; height: number } {
return viewportFor(video);
}
/**
* The final mp4's resolution (after the lanczos upscale pass in render.sh).
* Storyboards drive their on-screen typography from CSS viewport sizes, but
* social platforms care about the file resolution so we expose a
* separate getter for the published dimensions.
*/
export function publishedSizeFor(video: VideoConfig): { width: number; height: number } {
const viewport = viewportFor(video);
const scale = Math.max(1, Math.round(video.captureScale));
return { width: viewport.width * scale, height: viewport.height * scale };
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import type { Page } from 'playwright';
import { DashboardRecorder } from './dashboard.js';
import { installCursor, installZoomWrapper } from './dom.js';
import { installCursor, installZoomWrapper, setAspectClass } from './dom.js';
import { sleep } from './motion.js';
import { dashboardUrl } from './routes.js';
import { runStoryboard, type RunnerResult } from './runner.js';
@ -30,6 +30,7 @@ export async function prepareTimeline(
await sleep(400);
await installZoomWrapper(page);
await installCursor(page);
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);

View file

@ -1,7 +1,7 @@
import { execFileSync } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { OUTPUT_DIR } from './config.js';
import { recordedSizeFor, type Storyboard } from './script.js';
import { publishedSizeFor, recordedSizeFor, type Storyboard } from './script.js';
import { getStoryboard } from './storyboard.js';
interface Probe {
@ -58,7 +58,10 @@ function verifyVideo(path: string, storyboard: Storyboard) {
const stream = data.streams?.[0];
if (!stream) fail(`${path} has no video stream`);
const expectedSize = recordedSizeFor(storyboard.video);
// .webm is at CSS-pixel size; .mp4 is upscaled to publishedSize.
const expectedSize = path.endsWith('.webm')
? recordedSizeFor(storyboard.video)
: publishedSizeFor(storyboard.video);
const { minDurationS, maxDurationS, outputFps } = storyboard.video;
const duration = Number(data.format?.duration ?? 0);