Videos
BIN
frontend/public/video/recording-de-mobile.jpg
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
frontend/public/video/recording-de-mobile.mp4
Normal file
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 371 KiB |
BIN
frontend/public/video/recording-hi-mobile.jpg
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
frontend/public/video/recording-hi-mobile.mp4
Normal file
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 385 KiB |
BIN
frontend/public/video/recording-mobile.jpg
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
frontend/public/video/recording-mobile.mp4
Normal file
BIN
frontend/public/video/recording-zh-mobile.jpg
Normal file
|
After Width: | Height: | Size: 300 KiB |
BIN
frontend/public/video/recording-zh-mobile.mp4
Normal file
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 392 KiB |
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
278
video/src/dom.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||