This commit is contained in:
Andras Schmelczer 2026-05-06 22:40:46 +01:00
parent 28323f145e
commit 94f9c0d594
76 changed files with 3238 additions and 1230 deletions

View file

@ -28,7 +28,7 @@ AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if auth.json older than this
# the built bundle, so updating this path is what makes the new clip appear
# on the homepage. Override if the dashboard ever moves.
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
# When in the *output* timeline (post-speedup) to grab the poster frame.
# When in the output timeline to grab the poster frame.
# Right-pane inspection (~16s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.

View file

@ -1,4 +1,4 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { AUTH_STATE_PATH, CAPTURE_SCALE, OUTPUT_DIR, VIDEO_SIZE, VIEWPORT } from './config.js';
export interface RecordingBrowser {
@ -11,10 +11,15 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--enable-gpu',
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--use-angle=gl-egl',
'--ignore-gpu-blocklist',
'--enable-webgl',
'--enable-webgl2',
'--enable-gpu-rasterization',
'--enable-zero-copy',
'--disable-software-rasterizer',
'--disable-frame-rate-limit',
'--disable-gpu-vsync',
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling',
@ -34,6 +39,33 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
return { browser, context };
}
export async function assertHardwareWebGL(page: Page): Promise<void> {
const info = await page.evaluate(() => {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
if (!gl) return { webgl: false, vendor: '', renderer: '' };
const ext = gl.getExtension('WEBGL_debug_renderer_info');
const vendor = String(
ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR)
);
const renderer = String(
ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)
);
return { webgl: true, vendor, renderer };
});
console.log(`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : 'none'}`);
if (
process.env.ALLOW_SOFTWARE_GL !== '1' &&
(!info.webgl || /SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`))
) {
throw new Error(
'Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.'
);
}
}
async function suppressDevServerNoise(context: BrowserContext) {
await context.addInitScript(() => {
const RealWS = window.WebSocket;

View file

@ -7,16 +7,15 @@ export const OUTPUT_DIR = 'output';
const aspect = process.env.ASPECT ?? '16x9';
export const VIEWPORT =
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1.5));
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1));
export const VIDEO_SIZE = {
width: VIEWPORT.width,
height: VIEWPORT.height,
};
export const WEBM_BITRATE = process.env.WEBM_BITRATE ?? (CAPTURE_SCALE > 1 ? '18M' : '8M');
// Cold-open prompt. Punchy version of the user's intent — short enough that
// the typing animation fits in the AI scene without throttling pushing past
// the trim window. Each char costs ~80ms wall under boot CPU load.
// Cold-open prompt. Punchy version of the user's intent, short enough to type
// on camera without making the opening scene drag.
export const PROMPT_TEXT =
process.env.PROMPT_TEXT ?? 'Flats or terraces <£450k, 35 min to Manchester, low crime';
@ -66,16 +65,11 @@ export const INITIAL_MAP_VIEW = {
zoom: 11.5,
};
// Verification guard only. The renderer no longer uses this as an editing cap:
// Verification guard only. The renderer does not use this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 45);
export const MIN_DURATION_S = Number(process.env.MIN_DURATION_S ?? 10);
// Slow down all interactions while recording, then speed the output back up in
// ffmpeg. A higher scale makes rendering take longer, but gives the 25fps raw
// recorder enough unique frames for a smooth 50fps final without shortcut cuts.
export const RECORD_SCALE = Math.max(1, Number(process.env.RECORD_SCALE ?? 3.5));
// Target fps of the FINAL output.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50);

View file

@ -21,6 +21,22 @@ interface HexagonSnapshot {
bounds: Bounds;
}
interface PostcodeFeature {
type?: string;
properties: {
postcode: string;
count: number;
centroid: [number, number];
[key: string]: unknown;
};
[key: string]: unknown;
}
interface PostcodeSnapshot {
features: PostcodeFeature[];
bounds: Bounds;
}
export interface HexagonClickTarget {
h3: string;
x: number;
@ -46,6 +62,7 @@ export class DashboardRecorder {
private mapDataVersion = 0;
private selectionStatsVersion = 0;
private lastHexagons: HexagonSnapshot | null = null;
private lastPostcodes: PostcodeSnapshot | null = null;
constructor(private readonly page: Page) {
page.on('request', (request) => {
@ -78,6 +95,9 @@ export class DashboardRecorder {
}
async visibleHexagonTargets(limit = 8): Promise<HexagonClickTarget[]> {
const postcodeTargets = await this.visiblePostcodeTargets(limit);
if (postcodeTargets.length > 0) return postcodeTargets;
const snapshot = this.lastHexagons;
if (!snapshot || snapshot.features.length === 0) {
throw new Error('No recorded hexagon response is available for map clicking');
@ -146,6 +166,11 @@ export class DashboardRecorder {
}
if (kind === 'postcodes') {
const body = await response.json().catch(() => null);
const snapshot = parsePostcodeSnapshot(response.url(), body);
if (snapshot) {
this.lastPostcodes = snapshot;
}
this.mapDataVersion += 1;
return;
}
@ -217,6 +242,56 @@ export class DashboardRecorder {
.catch(() => false);
return !loading && !connecting;
}
private async visiblePostcodeTargets(limit: number): Promise<HexagonClickTarget[]> {
const snapshot = this.lastPostcodes;
if (!snapshot || snapshot.features.length === 0) return [];
const mapBox = await this.page.locator('[data-tutorial="map"]').boundingBox();
if (!mapBox) throw new Error('Map container has no bounding box');
const projected = snapshot.features
.filter((feature) => feature.properties.count > 0)
.map((feature) => {
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 distanceFromCenter = Math.hypot(
(point.x - centerX) / (mapBox.width / 2),
(point.y - centerY) / (mapBox.height / 2)
);
return {
h3: feature.properties.postcode,
x: point.x,
y: point.y,
count: feature.properties.count,
score: feature.properties.count / (1 + distanceFromCenter * 0.25),
};
})
.filter((target): target is HexagonClickTarget & { score: number } => target != null);
const onScreen = projected.filter(
(target) =>
target.x >= mapBox.x &&
target.x <= mapBox.x + mapBox.width &&
target.y >= mapBox.y &&
target.y <= mapBox.y + mapBox.height
);
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
);
const candidates = (clearOfChrome.length > 0 ? clearOfChrome : onScreen).sort(
(a, b) => b.score - a.score
);
return candidates.slice(0, limit).map(({ score: _score, ...target }) => target);
}
}
function classifyApiRequest(rawUrl: string): ApiKind | null {
@ -250,6 +325,18 @@ function parseHexagonSnapshot(rawUrl: string, body: unknown): HexagonSnapshot |
return { features, bounds };
}
function parsePostcodeSnapshot(rawUrl: string, body: unknown): PostcodeSnapshot | null {
if (!isRecord(body) || !Array.isArray(body.features)) return null;
const features = body.features.filter(isPostcodeFeature);
if (features.length === 0) return null;
const url = new URL(rawUrl);
const bounds = parseBounds(url.searchParams.get('bounds'));
if (!bounds) return null;
return { features, bounds };
}
function parseBounds(value: string | null): Bounds | null {
if (!value) return null;
const [south, west, north, east] = value.split(',').map(Number);
@ -271,8 +358,21 @@ function isHexagonFeature(value: unknown): value is HexagonFeature {
);
}
function isPostcodeFeature(value: unknown): value is PostcodeFeature {
if (!isRecord(value) || !isRecord(value.properties)) return false;
const { postcode, count, centroid } = value.properties;
return (
typeof postcode === 'string' &&
typeof count === 'number' &&
Array.isArray(centroid) &&
centroid.length === 2 &&
typeof centroid[0] === 'number' &&
typeof centroid[1] === 'number'
);
}
function projectFromBounds(
feature: HexagonFeature,
feature: { lat: number; lon: number },
bounds: Bounds,
mapBox: { x: number; y: number; width: number; height: number }
): { x: number; y: number } | null {

View file

@ -1,5 +1,4 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
/**
* Inject a visible cursor that mirrors the real mouse position. The browser's
@ -373,7 +372,7 @@ export async function zoomTo(
opts: { scale: number; focusX: number; focusY: number; durationMs?: number }
): Promise<void> {
const { scale, focusX, focusY, durationMs = 1100 } = opts;
const transitionMs = Math.round(durationMs * RECORD_SCALE);
const transitionMs = Math.round(durationMs);
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
const dx = viewport.width / 2 - scale * focusX;
const dy = viewport.height / 2 - scale * focusY;
@ -390,7 +389,7 @@ export async function zoomTo(
/** Animate the wrapper back to identity transform. */
export async function zoomReset(page: Page, durationMs = 1100): Promise<void> {
const transitionMs = Math.round(durationMs * RECORD_SCALE);
const transitionMs = Math.round(durationMs);
await page.evaluate((transitionMs) => {
const wrap = document.getElementById('__demo-zoom-wrap');
if (!wrap) return;

View file

@ -1,12 +1,7 @@
import type { Page } from 'playwright';
import { RECORD_SCALE } from './config.js';
// All timing primitives multiply by RECORD_SCALE. Scenes call them with
// "human-time" durations; the actual wall-clock pause is N× longer so the
// renderer has more time per visual frame. ffmpeg later speeds the output
// back up, so the *visible* animation speed in the final video is unchanged.
export const sleep = (ms: number) =>
new Promise<void>((r) => setTimeout(r, ms * RECORD_SCALE));
new Promise<void>((r) => setTimeout(r, ms));
// Cubic ease-in-out: slow start and end, fast middle. Reads as "natural" motion.
export const easeInOut = (t: number): number =>
@ -16,28 +11,18 @@ interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
realMouse?: boolean;
/**
* Override the per-step CDP cost used to size the loop. During a drag, every
* mouse.move fires a pointermove -> React re-render -> thumb position update
* on the same thread, pushing effective per-step cost higher. Pass that for drags
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
*/
stepBudgetMs?: number;
}
// Empirical Playwright-to-Chromium CDP roundtrip cost for a mouse.move command
// while recording the software-GL dashboard.
const CDP_MOVE_MS = 70;
const RAW_RECORDING_FRAME_MS = 40;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
* The injected cursor follows via its mousemove listener.
*
* Why no explicit sleep between steps: each `await page.mouse.move(...)` is a
* synchronous WebSocket round-trip to Chromium. Adding a setTimeout on top
* means the loop runs at `cdp_latency + sleepMs`, overshooting wallDuration
* by ~3×. We instead size `steps = wallDuration / CDP_MOVE_MS` so the loop's
* natural pace lands on the target wall duration.
* Real mouse moves are paced by wall-clock deadlines instead of CDP latency.
* The old version relied on slow software WebGL making each mouse.move call
* expensive; with hardware GPU those calls return quickly and animations
* collapse into a burst unless we explicitly pace them.
*/
export async function smoothMove(
page: Page,
@ -47,10 +32,9 @@ export async function smoothMove(
durationMs = 600,
ease = easeInOut,
realMouse = false,
stepBudgetMs = CDP_MOVE_MS,
}: MoveOptions = {}
): Promise<void> {
const wallDuration = durationMs * RECORD_SCALE;
const wallDuration = durationMs;
if (!realMouse) {
const animated = await page.evaluate(
({ x, y, wallDuration }) => {
@ -72,20 +56,24 @@ export async function smoothMove(
}
}
const steps = Math.max(2, Math.min(96, Math.round(wallDuration / stepBudgetMs)));
const steps = Math.max(2, Math.min(160, Math.round(wallDuration / RAW_RECORDING_FRAME_MS)));
const start = Date.now();
for (let i = 1; i <= steps; i++) {
const t = ease(i / steps);
const x = from.x + (to.x - from.x) * t;
const y = from.y + (to.y - from.y) * t;
await page.mouse.move(x, y);
const targetElapsed = (wallDuration * i) / steps;
const waitMs = start + targetElapsed - Date.now();
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
/**
* "Fake" type: progressively set the textarea value, dispatching
* React-compatible input events. This is Node-driven instead of browser
* setInterval-driven because 4K software WebGL can starve page timers and
* stretch a two-second typing beat into a minute.
* React-compatible input events. This stays Node-driven so typing cadence is
* stable even when the map is busy rendering.
*/
export async function fakeType(
page: Page,
@ -93,7 +81,6 @@ export async function fakeType(
text: string,
delayMs: number
): Promise<void> {
const delay = delayMs * RECORD_SCALE;
const steps = text.length;
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
@ -112,10 +99,28 @@ export async function fakeType(
},
{ selector, value: text.slice(0, end) }
);
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
if (delayMs > 0 && i < steps) {
await new Promise((resolve) =>
setTimeout(resolve, humanTypingDelay(text[i - 1], text[i], i, delayMs))
);
}
}
}
function humanTypingDelay(
char: string,
nextChar: string | undefined,
index: number,
baseDelayMs: number
): number {
const cadence = [0.82, 1.08, 0.94, 1.22, 0.88, 1.14, 0.98, 1.28];
let delay = baseDelayMs * cadence[index % cadence.length];
if (char === ' ') delay += baseDelayMs * 0.9;
if (/[,.!?;:]/.test(char)) delay += baseDelayMs * 1.8;
if (nextChar === ' ' && index % 4 === 0) delay += baseDelayMs * 0.55;
return Math.round(delay);
}
/**
* Drag the right-hand thumb of a Radix slider to a target track fraction.
* Returns the final cursor position so callers can chain a smoothMove afterwards.
@ -136,7 +141,6 @@ export async function smoothDragSliderThumb(
const thumbCy = thumbBox.y + thumbBox.height / 2;
const targetX = trackBox.x + trackBox.width * toFraction;
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
await page.mouse.down();
// The user explicitly prefers a longer render over stepped motion, so use
@ -145,7 +149,7 @@ export async function smoothDragSliderThumb(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs, realMouse: true, stepBudgetMs: 135 }
{ durationMs, realMouse: true }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };

View file

@ -1,7 +1,7 @@
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { AUTH_STATE_PATH, OUTPUT_DIR } from './config.js';
import { launchRecordingBrowser } from './browser.js';
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
import { installDemoRoutes } from './routes.js';
import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js';
@ -15,6 +15,7 @@ async function main() {
const { browser, context } = await launchRecordingBrowser();
const page = await context.newPage();
await assertHardwareWebGL(page);
const recordedVideo = page.video();
const recordStartMs = Date.now();

View file

@ -28,6 +28,14 @@ export interface SceneCtx {
cursor: { x: number; y: number };
}
const AI_CLOSEUP_ZOOM_MS = 1400;
const RESULTS_ZOOM_OUT_MS = 1500;
const EXPORT_ZOOM_OUT_MS = 1100;
const PROMPT_TYPING_DELAY_MS = 64;
const MAP_ZOOM_WHEEL_STEPS = 18;
const MAP_ZOOM_WHEEL_DELTA = -120;
const MAP_ZOOM_WHEEL_PAUSE_MS = 70;
/**
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
* stubbed, while the map filters and right pane are loaded from the real app.
@ -39,10 +47,15 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
await sleep(180);
await zoomToAiBox(page, 720);
await sleep(760);
await zoomToAiBox(page, AI_CLOSEUP_ZOOM_MS);
await sleep(AI_CLOSEUP_ZOOM_MS + 160);
await fakeType(page, '[data-tutorial="ai-filters"] textarea', PROMPT_TEXT, 18);
await fakeType(
page,
'[data-tutorial="ai-filters"] textarea',
PROMPT_TEXT,
PROMPT_TYPING_DELAY_MS
);
await sleep(160);
const aiResponse = page
.waitForResponse(
@ -71,17 +84,16 @@ export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
await zoomReset(page, 860);
await sleep(980);
await zoomReset(page, RESULTS_ZOOM_OUT_MS);
await sleep(RESULTS_ZOOM_OUT_MS + 160);
await hideCaption(page);
await sleep(180);
}
/**
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
* 35 to 20 minutes. The slider has step=1 over 0120, so the 15-minute
* range crosses 15 step boundaries at our pace each one gets ~20+ recorded
* frames, so the thumb reads as a continuous slide rather than incremental.
* 35 to 20 minutes. The slider has step=1 over 0120, so the drag is paced
* with real pointer updates instead of jumping the value directly.
*
* The card we drag (`tt_0`) only exists because the AI filter step inserted
* exactly one travel-time entry; if you change the AI stub's count, update
@ -135,17 +147,18 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
const cluster = {
const defaultCluster = {
x: 360 + (viewport.width - 360) * 0.35,
y: viewport.height * 0.52,
};
const cluster = await pickMapZoomTarget(ctx, defaultCluster);
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
ctx.cursor = cluster;
await sleep(220);
await zoomMapWithWheel(ctx, cluster);
ctx.cursor = await clickVisibleHexagon(ctx);
ctx.cursor = await clickVisibleHexagon(ctx, cluster);
await sleep(360);
await showCaption(
page,
@ -155,24 +168,43 @@ export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
await hideCaption(page);
}
async function pickMapZoomTarget(
ctx: SceneCtx,
fallback: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const [target] = await ctx.dashboard.visibleHexagonTargets(1).catch(() => []);
return target ? { x: target.x, y: target.y } : fallback;
}
async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
const { page, dashboard } = ctx;
const mapVersion = dashboard.getMapDataVersion();
await page.mouse.move(target.x, target.y);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await sleep(95);
for (let i = 0; i < MAP_ZOOM_WHEEL_STEPS; i++) {
await page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
await sleep(MAP_ZOOM_WHEEL_PAUSE_MS);
}
await dashboard.waitForMapSettled(mapVersion, 16000);
await sleep(260);
}
async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: number }> {
const candidates = await ctx.dashboard.visibleHexagonTargets(8);
async function clickVisibleHexagon(
ctx: SceneCtx,
fallbackTarget: { x: number; y: number }
): Promise<{ x: number; y: number }> {
const candidates = await ctx.dashboard.visibleHexagonTargets(8).catch((error) => {
console.log(
`[scene] Falling back to direct map click targets: ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
});
const clickTargets = await addFallbackClickTargets(ctx, candidates, fallbackTarget);
const startedAt = ctx.dashboard.getSelectionStatsVersion();
let lastError: Error | null = null;
for (const target of candidates) {
for (const target of clickTargets) {
await moveAndClickHexagon(ctx, target);
try {
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
@ -192,6 +224,39 @@ async function clickVisibleHexagon(ctx: SceneCtx): Promise<{ x: number; y: numbe
);
}
async function addFallbackClickTargets(
ctx: SceneCtx,
candidates: HexagonClickTarget[],
fallbackTarget: { x: number; y: number }
): Promise<HexagonClickTarget[]> {
const mapBox = await ctx.page.locator('[data-tutorial="map"]').boundingBox();
const fallbacks: HexagonClickTarget[] = [
{
h3: 'direct-target',
x: fallbackTarget.x,
y: fallbackTarget.y,
count: 1,
},
];
if (mapBox) {
fallbacks.push({
h3: 'map-center',
x: mapBox.x + mapBox.width / 2,
y: mapBox.y + mapBox.height / 2,
count: 1,
});
}
const seen = new Set<string>();
return [...candidates, ...fallbacks].filter((target) => {
const key = `${Math.round(target.x / 12)},${Math.round(target.y / 12)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise<void> {
await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 });
ctx.cursor = { x: target.x, y: target.y };
@ -204,8 +269,8 @@ export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
const { page } = ctx;
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
await zoomReset(page, 680);
await sleep(520);
await zoomReset(page, EXPORT_ZOOM_OUT_MS);
await sleep(EXPORT_ZOOM_OUT_MS + 120);
const exportButton = page.locator('button[title="Export to Excel"]').first();
await exportButton.waitFor({ state: 'visible', timeout: 4000 });

View file

@ -1,12 +1,6 @@
import { execSync } from 'node:child_process';
import { renameSync, statSync } from 'node:fs';
import {
MAX_DURATION_S,
OUTPUT_FPS,
RECORD_SCALE,
VIDEO_SIZE,
WEBM_BITRATE,
} from './config.js';
import { MAX_DURATION_S, OUTPUT_FPS, VIDEO_SIZE, WEBM_BITRATE } from './config.js';
const LEAD_IN_S = 0.12;
@ -18,11 +12,11 @@ export function trimRecording(
const sceneSpan = (times.sceneEndMs - times.sceneStartMs) / 1000;
const trimStart = Math.max(
0,
(times.sceneStartMs - times.recordStartMs) / 1000 - LEAD_IN_S * RECORD_SCALE
(times.sceneStartMs - times.recordStartMs) / 1000 - LEAD_IN_S
);
const trimEnd = (times.sceneEndMs - times.recordStartMs) / 1000;
const wallDuration = trimEnd - trimStart;
const finalDuration = wallDuration / RECORD_SCALE;
const finalDuration = wallDuration;
if (finalDuration > MAX_DURATION_S) {
console.log(
@ -32,12 +26,11 @@ export function trimRecording(
const filter =
`trim=start=${trimStart.toFixed(3)}:duration=${wallDuration.toFixed(3)},` +
`setpts=(PTS-STARTPTS)/${RECORD_SCALE},fps=${OUTPUT_FPS},` +
`setpts=PTS-STARTPTS,fps=${OUTPUT_FPS},` +
`trim=duration=${finalDuration.toFixed(3)},setpts=PTS-STARTPTS`;
// Keep trimming inside the filter graph: it is frame-accurate for WebM
// without the keyframe leakage of input seeking or the post-speedup timing
// ambiguity of output seeking.
// without the keyframe leakage of input seeking.
execSync(
`ffmpeg -y -i "${rawPath}" -vf "${filter}" ` +
`-fps_mode cfr -r ${OUTPUT_FPS} -c:v libvpx -b:v ${WEBM_BITRATE} -deadline good -cpu-used 5 ` +
@ -53,6 +46,6 @@ export function trimRecording(
}
console.log(
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${(sceneSpan / RECORD_SCALE).toFixed(2)}s, scale=${RECORD_SCALE}, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s, scene=${sceneSpan.toFixed(2)}s, capture=${VIDEO_SIZE.width}x${VIDEO_SIZE.height})`
);
}