Tonight
This commit is contained in:
parent
28323f145e
commit
94f9c0d594
76 changed files with 3238 additions and 1230 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 0–120, 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 0–120, 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 });
|
||||
|
|
|
|||
|
|
@ -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})`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue