/** * qa-journey-recorder.mjs — shared support for journey QA gates (not a gate). * * - `createScreencast(page, dir)` records CDP `Page.startScreencast` frames * (every compositor frame) with their real timestamps and builds * `/clip.mp4` through ffmpeg's concat demuxer with per-frame * durations, so held frames last as long as they did on screen. * - `installFrameProbe(page)` measures every animation frame in the page: * the requestAnimationFrame interval and, for frames Cesium rendered, the * CPU time between `scene.preUpdate` and `scene.postRender`. * - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95. * - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel * zoom rather than teleporting it. */ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; /** Percentile of a numeric list (nearest rank); NaN for an empty list. */ export function percentile(values, p) { if (!values.length) return Number.NaN; const sorted = [...values].sort((a, b) => a - b); return sorted[ Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1) ]; } /** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */ export function frameStats(frames) { const intervals = frames.map((f) => f.dt).filter(Number.isFinite); const renders = frames.map((f) => f.renderMs).filter(Number.isFinite); const round = (v) => (Number.isFinite(v) ? Math.round(v * 10) / 10 : null); const mean = (list) => list.length ? list.reduce((a, b) => a + b, 0) / list.length : Number.NaN; return { frames: intervals.length, frameMean: round(mean(intervals)), frameP50: round(percentile(intervals, 50)), frameP95: round(percentile(intervals, 95)), longFrames: intervals.filter((v) => v > 50).length, renderedFrames: renders.length, renderMean: round(mean(renders)), renderP50: round(percentile(renders, 50)), renderP95: round(percentile(renders, 95)), }; } /** Install the in-page frame probe once per document; returns nothing. */ export async function installFrameProbe(page) { await page.evaluate(() => { if (window.__gevFrameProbe) return; const { viewer } = window.__godsEyeView; const probe = { frames: [], started: 0 }; window.__gevFrameProbe = probe; let last = performance.now(); let renderStart = null; let pendingRender = null; viewer.scene.preUpdate.addEventListener(() => { renderStart = performance.now(); }); viewer.scene.postRender.addEventListener(() => { if (renderStart !== null) pendingRender = performance.now() - renderStart; renderStart = null; }); const tick = (now) => { probe.frames.push({ t: now, dt: now - last, renderMs: pendingRender }); if (probe.frames.length > 40_000) probe.frames.splice(0, 10_000); pendingRender = null; last = now; requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); } /** Read probe frames with t in [t0, t1) (page performance.now() times). */ export async function readFrames(page, t0, t1 = Infinity) { return page.evaluate( (a, b) => window.__gevFrameProbe.frames.filter( (f) => f.t >= a && f.t < (b ?? Infinity), ), t0, Number.isFinite(t1) ? t1 : null, ); } /** Page clock in ms (performance.now()). */ export function pageNow(page) { return page.evaluate(() => performance.now()); } /** * Ease the camera to a view over `seconds`, resolving when it lands. * @param {import('puppeteer').Page} page Page. * @param {{lat:number, lon:number, height:number, heading?:number, pitch?:number}} view * Destination (degrees, metres). * @param {number} seconds Flight duration; 0 jumps. */ export async function moveCamera(page, view, seconds) { await page.evaluate( (v, duration) => new Promise((resolve) => { const { viewer } = window.__godsEyeView; const C = viewer.camera; C.cancelFlight(); C.flyTo({ destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({ latitude: (v.lat * Math.PI) / 180, longitude: (v.lon * Math.PI) / 180, height: v.height, }), orientation: { heading: ((v.heading ?? 0) * Math.PI) / 180, pitch: ((v.pitch ?? -45) * Math.PI) / 180, roll: 0, }, duration, // Straight, eased motion like a drag or wheel zoom, not an arc. maximumHeight: Math.max(v.height, C.positionCartographic.height), complete: resolve, cancel: resolve, }); }), view, seconds, ); } /** * Record the page with CDP screencast frames and real per-frame timing. * @param {import('puppeteer').Page} page Page to record. * @param {string} dir Output directory (frames + clip.mp4). * @param {{maxWidth?: number, maxHeight?: number, quality?: number, fps?: number|null}} [options] */ export async function createScreencast(page, dir, options = {}) { const { maxWidth = 1280, maxHeight = 800, quality = 70, fps = null, } = options; const framesDir = path.join(dir, 'frames'); fs.rmSync(framesDir, { recursive: true, force: true }); fs.mkdirSync(framesDir, { recursive: true }); const cdp = await page.createCDPSession(); const frames = []; let recording = false; cdp.on('Page.screencastFrame', async (frame) => { const file = path.join( framesDir, `f${String(frames.length).padStart(5, '0')}.jpg`, ); if (recording) { fs.writeFileSync(file, Buffer.from(frame.data, 'base64')); frames.push({ file, timestamp: frame.metadata.timestamp }); } await cdp .send('Page.screencastFrameAck', { sessionId: frame.sessionId }) .catch(() => {}); }); return { frames, async start() { recording = true; await cdp.send('Page.startScreencast', { format: 'jpeg', quality, maxWidth, maxHeight, everyNthFrame: 1, }); }, /** Stop, encode with real timing, and return clip facts. */ async stop() { recording = false; await cdp.send('Page.stopScreencast').catch(() => {}); await cdp.detach().catch(() => {}); if (frames.length < 2) return { clip: null, frames: frames.length }; const lines = []; for (let i = 0; i < frames.length; i++) { const next = frames[i + 1]?.timestamp; const duration = next ? Math.min(2, Math.max(0.001, next - frames[i].timestamp)) : 0.05; lines.push(`file '${path.resolve(frames[i].file)}'`); lines.push(`duration ${duration.toFixed(4)}`); } // The concat demuxer ignores the last duration unless the file repeats. lines.push(`file '${path.resolve(frames.at(-1).file)}'`); const list = path.join(dir, 'frames.txt'); fs.writeFileSync(list, `${lines.join('\n')}\n`); const clip = path.join(dir, 'clip.mp4'); execFileSync('ffmpeg', [ '-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list, '-fps_mode', fps ? 'cfr' : 'vfr', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-vf', `scale=trunc(iw/2)*2:trunc(ih/2)*2${fps ? `,fps=${fps}` : ''}`, clip, ]); const seconds = frames.at(-1).timestamp - frames[0].timestamp; return { clip, frames: frames.length, seconds: Math.round(seconds * 10) / 10, fps: Math.round((frames.length / Math.max(seconds, 0.001)) * 10) / 10, }; }, }; }