This commit is contained in:
Andras Schmelczer 2026-05-13 12:12:11 +01:00
parent b98f0e3904
commit a8165249a4
24 changed files with 1486 additions and 105 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 371 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Before After
Before After

Binary file not shown.

View file

@ -5,7 +5,7 @@ import {
type Page,
} from 'playwright';
import { AUTH_STATE_PATH } from './config.js';
import { viewportFor, type Storyboard } from './script.js';
import { recordedSizeFor, viewportFor, type Storyboard } from './script.js';
export interface RecordingBrowser {
browser: Browser;
@ -44,11 +44,12 @@ export async function launchRecordingBrowser(
});
const viewport = viewportFor(storyboard.video);
const recordSize = recordedSizeFor(storyboard.video);
const context = await browser.newContext({
storageState: AUTH_STATE_PATH,
viewport,
deviceScaleFactor: storyboard.video.captureScale,
recordVideo: { dir: opts.recordDir, size: viewport },
recordVideo: { dir: opts.recordDir, size: recordSize },
});
await context.addInitScript((appLanguage) => {
if (appLanguage) localStorage.setItem('language', appLanguage);

View file

@ -105,17 +105,18 @@ export class DashboardRecorder {
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
const clear = await this.clickableBox(mapBox);
const projected = snapshot.features
.filter((feature) => feature.count > 0)
.map((feature) => {
const point = projectFromBounds(feature, snapshot.bounds, mapBox);
if (!point) return null;
const centerX = mapBox.x + mapBox.width / 2;
const centerY = mapBox.y + mapBox.height / 2;
const centerX = clear.left + (clear.right - clear.left) / 2;
const centerY = clear.top + (clear.bottom - clear.top) / 2;
const distanceFromCenter = Math.hypot(
(point.x - centerX) / (mapBox.width / 2),
(point.y - centerY) / (mapBox.height / 2)
(point.x - centerX) / Math.max(1, (clear.right - clear.left) / 2),
(point.y - centerY) / Math.max(1, (clear.bottom - clear.top) / 2)
);
return {
h3: feature.h3,
@ -136,10 +137,10 @@ export class DashboardRecorder {
);
const clearOfChrome = onScreen.filter(
(target) =>
target.x >= mapBox.x + 80 &&
target.x <= mapBox.x + mapBox.width - 130 &&
target.y >= mapBox.y + 105 &&
target.y <= mapBox.y + mapBox.height - 115
target.x >= clear.left &&
target.x <= clear.right &&
target.y >= clear.top &&
target.y <= clear.bottom
);
const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort(
@ -151,6 +152,37 @@ export class DashboardRecorder {
return candidates.slice(0, limit).map(({ score: _score, ...target }) => target);
}
/**
* 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)
* the floating MobileBottomSheet. We detect the sheet via the only
* `section.rounded-t-2xl` in the DOM and treat its top as a hard
* bottom-clear limit; without that, hex() would happily return a
* polygon hidden under the sheet on 9x16 cuts.
*/
private async clickableBox(mapBox: {
x: number;
y: number;
width: number;
height: number;
}): Promise<{ top: number; bottom: number; left: number; right: number }> {
let bottomClear = mapBox.y + mapBox.height - 115;
const sheet = await this.page
.locator('section[class*="rounded-t-2xl"]')
.first()
.boundingBox()
.catch(() => null);
if (sheet && sheet.y > mapBox.y && sheet.y < bottomClear) {
bottomClear = sheet.y - 16;
}
return {
top: mapBox.y + 105,
bottom: bottomClear,
left: mapBox.x + 80,
right: mapBox.x + mapBox.width - 130,
};
}
private async captureResponse(response: Response): Promise<void> {
const kind = classifyApiRequest(response.url());
if (!kind || !response.ok()) return;
@ -249,6 +281,7 @@ export class DashboardRecorder {
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
const clear = await this.clickableBox(mapBox);
const projected = snapshot.features
.filter((feature) => feature.properties.count > 0)
@ -256,11 +289,11 @@ export class DashboardRecorder {
const [lon, lat] = feature.properties.centroid;
const point = projectFromBounds({ lat, lon }, snapshot.bounds, mapBox);
if (!point) return null;
const centerX = mapBox.x + mapBox.width / 2;
const centerY = mapBox.y + mapBox.height / 2;
const centerX = clear.left + (clear.right - clear.left) / 2;
const centerY = clear.top + (clear.bottom - clear.top) / 2;
const distanceFromCenter = Math.hypot(
(point.x - centerX) / (mapBox.width / 2),
(point.y - centerY) / (mapBox.height / 2)
(point.x - centerX) / Math.max(1, (clear.right - clear.left) / 2),
(point.y - centerY) / Math.max(1, (clear.bottom - clear.top) / 2)
);
return {
h3: feature.properties.postcode,
@ -281,10 +314,10 @@ export class DashboardRecorder {
);
const clearOfChrome = onScreen.filter(
(target) =>
target.x >= mapBox.x + 80 &&
target.x <= mapBox.x + mapBox.width - 130 &&
target.y >= mapBox.y + 105 &&
target.y <= mapBox.y + mapBox.height - 115
target.x >= clear.left &&
target.x <= clear.right &&
target.y >= clear.top &&
target.y <= clear.bottom
);
const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort(

View file

@ -1,4 +1,5 @@
import type { Page } from 'playwright';
import type { AdScene } from './script.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
@ -153,6 +154,208 @@ export async function installCursor(page: Page): Promise<void> {
letter-spacing: 0;
color: #99f6e4;
}
.__ad-scene {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 2147483639;
opacity: 0;
transition: opacity 260ms ease-out;
color: #f8fafc;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0;
}
.__ad-scene.visible { opacity: 1; }
.__ad-scene.accent-teal { --ad-accent: #2dd4bf; --ad-accent-soft: rgba(45, 212, 191, 0.22); }
.__ad-scene.accent-sky { --ad-accent: #38bdf8; --ad-accent-soft: rgba(56, 189, 248, 0.22); }
.__ad-scene.accent-amber { --ad-accent: #f59e0b; --ad-accent-soft: rgba(245, 158, 11, 0.24); }
.__ad-scene.accent-rose { --ad-accent: #fb7185; --ad-accent-soft: rgba(251, 113, 133, 0.22); }
.__ad-scene.accent-lime { --ad-accent: #a3e635; --ad-accent-soft: rgba(163, 230, 53, 0.18); }
.__ad-scene.accent-violet { --ad-accent: #a78bfa; --ad-accent-soft: rgba(167, 139, 250, 0.22); }
.__ad-scrim {
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(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);
}
.__ad-frame {
position: absolute;
top: 118px;
left: 58px;
right: 58px;
bottom: 330px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 24px;
}
.__ad-kicker {
align-self: flex-start;
padding: 10px 14px;
border-radius: 8px;
color: #020617;
background: var(--ad-accent);
font: 800 28px/1 ui-sans-serif, system-ui, sans-serif;
text-transform: uppercase;
}
.__ad-comment {
max-width: 820px;
padding: 22px 24px;
border-radius: 8px;
background: rgba(248, 250, 252, 0.94);
color: #0f172a;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.34);
font: 700 34px/1.18 ui-sans-serif, system-ui, sans-serif;
}
.__ad-title {
margin: 0;
max-width: 940px;
color: #fff;
font: 850 82px/1.02 ui-sans-serif, system-ui, sans-serif;
text-wrap: balance;
}
.__ad-body {
max-width: 890px;
margin: 0;
color: #dbeafe;
font: 560 38px/1.24 ui-sans-serif, system-ui, sans-serif;
text-wrap: balance;
}
.__ad-split {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
margin-top: 4px;
}
.__ad-panel {
border-radius: 8px;
padding: 24px 26px;
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(226, 232, 240, 0.18);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.26);
}
.__ad-panel-title {
font: 800 42px/1.08 ui-sans-serif, system-ui, sans-serif;
color: #fff;
}
.__ad-panel-subtitle {
margin-top: 10px;
font: 570 28px/1.22 ui-sans-serif, system-ui, sans-serif;
color: #cbd5e1;
}
.__ad-panel-meta {
margin-top: 16px;
display: inline-flex;
padding: 8px 12px;
border-radius: 8px;
background: rgba(148, 163, 184, 0.18);
color: #e2e8f0;
font: 750 24px/1 ui-sans-serif, system-ui, sans-serif;
}
.__ad-items {
display: grid;
gap: 14px;
margin-top: 4px;
}
.__ad-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
min-height: 74px;
padding: 17px 20px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(226, 232, 240, 0.16);
box-shadow: 0 14px 42px rgba(0, 0, 0, 0.22);
}
.__ad-rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 8px;
color: #020617;
background: var(--ad-accent);
font: 850 24px/1 ui-sans-serif, system-ui, sans-serif;
}
.__ad-item-label {
color: #f8fafc;
font: 720 31px/1.13 ui-sans-serif, system-ui, sans-serif;
}
.__ad-item-value {
color: #bfdbfe;
font: 760 28px/1.1 ui-sans-serif, system-ui, sans-serif;
text-align: right;
}
.__ad-item.good { border-color: rgba(74, 222, 128, 0.45); }
.__ad-item.bad { border-color: rgba(251, 113, 133, 0.48); }
.__ad-item.warn { border-color: rgba(251, 191, 36, 0.48); }
.__ad-item.good .__ad-item-value { color: #86efac; }
.__ad-item.bad .__ad-item-value { color: #fda4af; }
.__ad-item.warn .__ad-item-value { color: #fde68a; }
.__ad-panel.good { border-color: rgba(74, 222, 128, 0.48); }
.__ad-panel.bad { border-color: rgba(251, 113, 133, 0.48); }
.__ad-panel.warn { border-color: rgba(251, 191, 36, 0.48); }
.__ad-footer {
margin-top: 6px;
color: #e0f2fe;
font: 760 31px/1.18 ui-sans-serif, system-ui, sans-serif;
}
.__ad-progress {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(226, 232, 240, 0.22);
}
.__ad-progress-fill {
height: 100%;
width: 0%;
background: var(--ad-accent);
box-shadow: 0 0 28px var(--ad-accent);
}
.__ad-scene.mode-title .__ad-frame {
justify-content: center;
}
.__ad-scene.mode-title .__ad-title {
font-size: 94px;
}
.__ad-scene.mode-comment .__ad-comment {
margin-bottom: 12px;
}
.__ad-scene.mode-tabs .__ad-items {
grid-template-columns: 1fr 1fr;
}
.__ad-scene.mode-tabs .__ad-item {
grid-template-columns: auto 1fr;
min-height: 86px;
}
.__ad-scene.mode-tabs .__ad-item-value {
display: none;
}
.__ad-scene.mode-scanner .__ad-frame::after,
.__ad-scene.mode-polygraph .__ad-frame::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 48%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--ad-accent), transparent);
box-shadow: 0 0 28px var(--ad-accent);
animation: __ad-scan 1.8s ease-in-out infinite;
}
@keyframes __ad-scan {
0% { transform: translateY(-180px); opacity: 0; }
18% { opacity: 1; }
82% { opacity: 1; }
100% { transform: translateY(180px); opacity: 0; }
}
`,
});
@ -296,6 +499,81 @@ export async function visualClick(
);
}
export async function showAdScene(page: Page, scene: AdScene): Promise<void> {
await page.evaluate((s) => {
const mode = s.mode ?? 'stack';
const accent = s.accent ?? 'teal';
const make = (tag: string, className: string, text?: string): HTMLElement => {
const el = document.createElement(tag);
el.className = className;
if (text) el.textContent = text;
return el;
};
const root =
document.getElementById('__ad-scene') ?? document.createElement('div');
root.id = '__ad-scene';
root.className = `__ad-scene mode-${mode} accent-${accent}`;
root.replaceChildren();
const scrim = make('div', '__ad-scrim');
const frame = make('div', '__ad-frame');
if (s.comment) frame.appendChild(make('div', '__ad-comment', s.comment));
if (s.kicker) frame.appendChild(make('div', '__ad-kicker', s.kicker));
frame.appendChild(make('h1', '__ad-title', s.title));
if (s.body) frame.appendChild(make('p', '__ad-body', s.body));
if (s.left || s.right) {
const split = make('div', '__ad-split');
for (const panel of [s.left, s.right]) {
if (!panel) continue;
const panelEl = make('div', `__ad-panel ${panel.tone ?? 'neutral'}`);
panelEl.appendChild(make('div', '__ad-panel-title', panel.title));
if (panel.subtitle) {
panelEl.appendChild(make('div', '__ad-panel-subtitle', panel.subtitle));
}
if (panel.meta) panelEl.appendChild(make('div', '__ad-panel-meta', panel.meta));
split.appendChild(panelEl);
}
frame.appendChild(split);
}
if (s.items?.length) {
const list = make('div', '__ad-items');
s.items.forEach((item, index) => {
const row = make('div', `__ad-item ${item.tone ?? 'neutral'}`);
row.appendChild(make('span', '__ad-rank', String(index + 1)));
row.appendChild(make('span', '__ad-item-label', item.label));
row.appendChild(make('span', '__ad-item-value', item.value ?? ''));
list.appendChild(row);
});
frame.appendChild(list);
}
if (typeof s.progress === 'number') {
const progress = make('div', '__ad-progress');
const fill = make('div', '__ad-progress-fill');
fill.style.width = `${Math.round(Math.max(0, Math.min(1, s.progress)) * 100)}%`;
progress.appendChild(fill);
frame.appendChild(progress);
}
if (s.footer) frame.appendChild(make('div', '__ad-footer', s.footer));
root.append(scrim, frame);
if (!root.parentElement) document.body.appendChild(root);
requestAnimationFrame(() => root.classList.add('visible'));
}, scene);
}
export async function hideAdScene(page: Page): Promise<void> {
await page.evaluate(() => {
document.getElementById('__ad-scene')?.classList.remove('visible');
});
}
export async function showOutro(
page: Page,
brand: string,

View file

@ -13,6 +13,19 @@ async function recordOne(storyboard: Storyboard): Promise<void> {
const dir = join(OUTPUT_DIR, storyboard.name);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const trimmedPath = join(dir, 'recording.webm');
const narrationPath = join(dir, 'narration.json');
if (
process.env.RESUME_RECORDINGS === '1' &&
existsSync(trimmedPath) &&
statSync(trimmedPath).size > 0 &&
existsSync(narrationPath) &&
statSync(narrationPath).size > 0
) {
console.log(`\n=== [${storyboard.name}] recording exists, skipping ===`);
return;
}
console.log(`\n=== [${storyboard.name}] recording ===`);
const { browser, context } = await launchRecordingBrowser(storyboard, {
@ -45,15 +58,15 @@ async function recordOne(storyboard: Storyboard): Promise<void> {
await page.close();
const rawPath = join(dir, 'recording.raw.webm');
if (recordedVideo) await recordedVideo.saveAs(rawPath);
await context.close();
if (recordedVideo) await recordedVideo.saveAs(rawPath);
await browser.close();
if (!recordedVideo || !statSync(rawPath).size) {
throw new Error(`[${storyboard.name}] no recorded webm found`);
}
trimRecording(rawPath, join(dir, 'recording.webm'), storyboard, {
trimRecording(rawPath, trimmedPath, storyboard, {
recordStartMs,
...timeline,
});
@ -61,7 +74,7 @@ async function recordOne(storyboard: Storyboard): Promise<void> {
const totalDurationMs =
timeline.sceneEndMs - timeline.sceneStartMs + LEAD_IN_S * 1000;
const cues = narrationLog.flush(
join(dir, 'narration.json'),
narrationPath,
totalDurationMs
);
console.log(

View file

@ -4,8 +4,10 @@ import type { Page } from 'playwright';
import { LEAD_IN_S, OUTPUT_DIR } from './config.js';
import {
clearVignette,
hideAdScene,
hideCaption,
setCursorScale,
showAdScene,
showCaption,
showOutro,
zoomReset,
@ -195,8 +197,9 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
const point = await resolveTarget(ctx, step.target);
await ctx.page.mouse.move(point.x, point.y);
const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps));
const delta = step.direction === 'out' ? -MAP_ZOOM_WHEEL_DELTA : MAP_ZOOM_WHEEL_DELTA;
for (let i = 0; i < step.steps; i++) {
await ctx.page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
await ctx.page.mouse.wheel(0, delta);
if (perStepMs > 0) await sleep(perStepMs);
}
return;
@ -219,6 +222,12 @@ async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
case 'showOutro':
await showOutro(ctx.page, step.brand, step.tagline, step.url);
return;
case 'showAdScene':
await showAdScene(ctx.page, step.scene);
return;
case 'hideAdScene':
await hideAdScene(ctx.page);
return;
}
}
@ -232,6 +241,13 @@ async function resolveTarget(
if (targets.length === 0) throw new Error('No visible hexagon to target');
return { x: targets[0].x, y: targets[0].y };
}
if (target.kind === 'viewportFraction') {
const viewport = ctx.page.viewportSize() ?? { width: 1920, height: 1080 };
return {
x: Math.round(Math.min(1, Math.max(0, target.xFrac)) * viewport.width),
y: Math.round(Math.min(1, Math.max(0, target.yFrac)) * viewport.height),
};
}
const box = await ctx.page.locator(target.selector).boundingBox();
if (!box) throw new Error(`No bounding box for selector: ${target.selector}`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };

View file

@ -22,7 +22,47 @@ export interface ScriptCtx {
cursor: { x: number; y: number };
}
/** A point on screen, either absolute pixel coords or the centre of an element. */
export type AdSceneAccent = 'teal' | 'sky' | 'amber' | 'rose' | 'lime' | 'violet';
export type AdSceneMode =
| 'title'
| 'stack'
| 'split'
| 'rank'
| 'comment'
| 'scanner'
| 'tabs'
| 'receipt'
| 'polygraph'
| 'match';
export interface AdSceneItem {
label: string;
value?: string;
tone?: 'good' | 'bad' | 'warn' | 'neutral';
}
export interface AdScenePanel {
title: string;
subtitle?: string;
meta?: string;
tone?: 'good' | 'bad' | 'warn' | 'neutral';
}
export interface AdScene {
mode?: AdSceneMode;
accent?: AdSceneAccent;
kicker?: string;
title: string;
body?: string;
comment?: string;
footer?: string;
progress?: number;
left?: AdScenePanel;
right?: AdScenePanel;
items?: AdSceneItem[];
}
/** A point on screen, resolved at runtime to viewport pixels. */
export type Target =
| { kind: 'point'; x: number; y: number }
| { kind: 'element'; selector: string }
@ -32,11 +72,24 @@ export type Target =
* level use this when the click MUST land on a polygon and a fixed pixel
* coordinate would risk landing on a road or river at deep zoom.
*/
| { kind: 'hexagon' };
| { kind: 'hexagon' }
/**
* Resolved at runtime to (xFrac·viewport.width, yFrac·viewport.height). Use
* this when an activity needs a stable visual location that scales with the
* aspect (e.g. "centre of the map area" on both 16x9 desktop and 9x16
* mobile, where the visible map is only the top portion above the bottom
* sheet). Both fractions are clamped to [0, 1].
*/
| { kind: 'viewportFraction'; xFrac: number; yFrac: number };
export const at = (x: number, y: number): Target => ({ kind: 'point', x, y });
export const el = (selector: string): Target => ({ kind: 'element', selector });
export const hex = (): Target => ({ kind: 'hexagon' });
export const vfrac = (xFrac: number, yFrac: number): Target => ({
kind: 'viewportFraction',
xFrac,
yFrac,
});
/**
* Activities are the runner's atomic operations. Each one has a fixed
@ -59,9 +112,17 @@ export type Activity =
| { kind: 'cursorScale'; scale: number; durationMs: number }
/**
* Wheel-zoom the underlying map at `target`. `steps` controls intensity
* (each step is one ~120px wheel notch).
* (each step is one ~120px wheel notch). `direction` picks zoom in (the
* default) or zoom out, so the same activity can both dive into a
* postcode and later pull back to the filtered overview.
*/
| { kind: 'mapZoom'; target: Target; steps: number; durationMs: number }
| {
kind: 'mapZoom';
target: Target;
steps: number;
durationMs: number;
direction?: 'in' | 'out';
}
/** Drag the right thumb of a Radix slider to a fraction in [0,1]. */
| {
kind: 'dragSlider';
@ -74,6 +135,10 @@ export type Activity =
| { kind: 'submitForm'; formSelector: string; durationMs: number }
/** Reveal the closing brand card. */
| { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number }
/** Reveal a full-screen ad-style overlay over the live map. */
| { kind: 'showAdScene'; scene: AdScene; durationMs: number }
/** Fade the ad overlay away. */
| { kind: 'hideAdScene'; durationMs: number }
/** Fade away the opening vignette. */
| { kind: 'clearVignette'; durationMs: number };
@ -100,10 +165,19 @@ export interface Cue {
/** Recorder + encoder knobs. Set per storyboard so vertical/horizontal cuts
* can coexist without env-var juggling. */
export interface VideoConfig {
/** "16x9" → 1920x1080, "9x16" → 1080x1920. */
/** "16x9" → 1920x1080, "9x16" → 1080x1920 by default. */
aspect: '16x9' | '9x16';
/** Browser deviceScaleFactor. >1 supersamples for sharper text. */
captureScale: number;
/**
* Optional CSS viewport override (in pixels). Lets a single storyboard
* record at a narrower CSS viewport than the aspect's default e.g.
* the recording-*-mobile cuts use 540x960 so Tailwind's `md:`
* breakpoint (768px) doesn't match and every component picks its
* mobile typography. Pair with `captureScale: 2` to keep text sharp.
* If unset, the viewport comes from `viewportFor(aspect)`.
*/
viewport?: { width: number; height: number };
/** WebM bitrate passed to libvpx, e.g. "8M" or "18M". */
webmBitrate: string;
/** Final fps after the trim/resample pass. */
@ -191,9 +265,28 @@ export interface Storyboard {
post?: Activity[];
}
/** Convenience: derive the viewport from aspect. */
/**
* Frontend viewport in CSS pixels. Defaults to the aspect's native size
* (1920x1080 for 16x9, 1080x1920 for 9x16). A storyboard can opt into a
* narrower CSS viewport via `video.viewport` e.g. recording-*-mobile
* uses 540x960 so the frontend's Tailwind `md:` breakpoint doesn't match
* and every component picks mobile typography/spacing. Pair the override
* with `captureScale: 2` to keep text sharp at the smaller resolution.
*/
export function viewportFor(video: VideoConfig): { width: number; height: number } {
if (video.viewport) return video.viewport;
return video.aspect === '9x16'
? { width: 1080, height: 1920 }
: { width: 1920, height: 1080 };
}
/**
* 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.
*/
export function recordedSizeFor(video: VideoConfig): { width: number; height: number } {
return viewportFor(video);
}

File diff suppressed because it is too large Load diff

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 { viewportFor, type Storyboard } from './script.js';
import { recordedSizeFor, type Storyboard } from './script.js';
import { getStoryboard } from './storyboard.js';
interface Probe {
@ -58,7 +58,7 @@ function verifyVideo(path: string, storyboard: Storyboard) {
const stream = data.streams?.[0];
if (!stream) fail(`${path} has no video stream`);
const expectedSize = viewportFor(storyboard.video);
const expectedSize = recordedSizeFor(storyboard.video);
const { minDurationS, maxDurationS, outputFps } = storyboard.video;
const duration = Number(data.format?.duration ?? 0);