#!/usr/bin/env node /** * fly_route cinematic evidence — drives the REAL voice runner headlessly and * measures the REAL camera (Cesium heading/pitch/roll + position) every * rendered frame, so the proof is the shot the owner will watch, not our own * internal numbers. * * node scripts/qa-flyroute-cinema.mjs --url http://localhost:4247 * * Writes a screenshot sequence plus a JSON trace to qa-shots/flyroute/. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; import sharp from 'sharp'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const getOpt = (flag, fallback) => { const i = process.argv.indexOf(flag); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; }; const APP_URL = getOpt('--url', 'http://localhost:4247'); const OUT_DIR = getOpt('--out', path.join(ROOT, 'qa-shots', 'flyroute')); const MIRROR_DIR = getOpt('--mirror', ''); const SHOT_EVERY_MS = Number(getOpt('--shot-ms', '2000')); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), ].filter(Boolean); const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }); // A 6-waypoint downtown Austin route: north, right, left, right, left. const ROUTE_POINTS = [ { latitude: 30.2620, longitude: -97.7431 }, { latitude: 30.2650, longitude: -97.7431 }, { latitude: 30.2650, longitude: -97.7397 }, { latitude: 30.2680, longitude: -97.7397 }, { latitude: 30.2680, longitude: -97.7363 }, { latitude: 30.2712, longitude: -97.7363 }, ]; const results = []; const report = (ok, name, detail = '') => { results.push({ ok, name, detail }); const mark = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; console.log(` ${mark} ${name}${detail ? ` — ${detail}` : ''}`); }; const note = (name, detail) => { results.push({ ok: null, name, detail }); console.log(` \x1b[33mNOTE\x1b[0m ${name}${detail ? ` — ${detail}` : ''}`); }; const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); const wrapDeg = (deg) => ((deg + 540) % 360) - 180; fs.mkdirSync(OUT_DIR, { recursive: true }); if (MIRROR_DIR) fs.mkdirSync(MIRROR_DIR, { recursive: true }); const browser = await puppeteer.launch({ headless: 'new', ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', // Real GPU when the host has one: the dolly is frame-rate independent, but // a higher sample rate makes the roll and the ease ramps far easier to see. ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader']), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1500,950', ], protocolTimeout: 240000, }); const page = await browser.newPage(); await page.setViewport({ width: 1500, height: 950 }); page.on('pageerror', (e) => console.log(` [page error] ${String(e).slice(0, 160)}`)); // Cold-corridor proof: hold the terrain proxy back so the dolly has to survive // a corridor that has no floor data when the flight is asked for. Without this // the harness only ever measures a machine whose cache happens to be warm. const TERRAIN_DELAY_MS = Number(getOpt('--terrain-delay-ms', '0')); let terrainRequests = 0; if (TERRAIN_DELAY_MS > 0) { await page.setRequestInterception(true); page.on('request', (request) => { if (request.url().includes('/api/terrain/heights')) { terrainRequests += 1; setTimeout(() => { request.continue().catch(() => {}); }, TERRAIN_DELAY_MS); return; } request.continue().catch(() => {}); }); } /** Install a postRender sampler: one row per RENDERED frame. */ async function installSampler() { await page.evaluate(() => { const viewer = window.__godsEyeView.viewer; window.__gevFlyTrace = { rows: [], marks: [] }; if (window.__gevFlyTraceRemove) window.__gevFlyTraceRemove(); let frame = 0; const listener = () => { const cam = viewer.camera; const carto = cam.positionCartographic; // Every 6th frame, ask the RENDERED WORLD what is under the camera. This // is the only measurement that can prove "never below terrain": it reads // the surface the user is actually looking at, not our own floor cache. let surfaceM = null; frame += 1; if (frame % 6 === 0 && typeof viewer.scene.sampleHeight === 'function') { try { const probe = viewer.scene.sampleHeight(carto.clone()); if (Number.isFinite(probe)) surfaceM = probe; } catch { /* tiles not loaded under the camera */ } } window.__gevFlyTrace.rows.push({ t: performance.now(), lon: (carto.longitude * 180) / Math.PI, lat: (carto.latitude * 180) / Math.PI, height: carto.height, headingDeg: (cam.heading * 180) / Math.PI, pitchDeg: (cam.pitch * 180) / Math.PI, rollDeg: (cam.roll * 180) / Math.PI, surfaceM, }); }; viewer.scene.postRender.addEventListener(listener); window.__gevFlyTraceRemove = () => viewer.scene.postRender.removeEventListener(listener); }); } async function readTrace() { return page.evaluate(() => ({ rows: window.__gevFlyTrace.rows.slice(), marks: window.__gevFlyTrace.marks.slice(), })); } /** Metres between two samples, on the ground plane plus height. */ function sampleDistanceM(a, b) { const mPerDegLat = 111320; const mPerDegLon = 111320 * Math.cos((a.lat * Math.PI) / 180); const dx = (b.lon - a.lon) * mPerDegLon; const dy = (b.lat - a.lat) * mPerDegLat; return Math.hypot(dx, dy); } try { console.log(`\nfly_route cinematic evidence — ${APP_URL}`); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.runner && window.__gevAnnotations, { timeout: 150000, polling: 250 }, ); const run = (name, args = {}) => page.evaluate( (n, a) => window.__gevVoiceCommands.runner(n, a), name, args, ); // ── Setup: get over downtown Austin, draw the route ─────────────────── await run('fly_to_location', { query: 'Texas State Capitol, Austin' }); await sleep(9000); await run('clear_annotations', {}); await sleep(500); const drawn = await run('annotate_map', { annotations: [{ type: 'route', mode: 'driving', label: 'cinema evidence route', points: ROUTE_POINTS, }], }); await sleep(6000); const routeInfo = await page.evaluate(() => { const route = (window.__gevAnnotations.list?.() || []).filter((a) => a.type === 'route').at(-1); return route ? { label: route.label, waypoints: route.path?.length ?? 0 } : null; }); report(Boolean(routeInfo?.waypoints >= 2), 'route drawn on the board', `waypoints=${routeInfo?.waypoints} drawn=${drawn?.drawn}`); if (!routeInfo) throw new Error('no route on the board — cannot fly it'); // ── Run 1: the full cinematic flight ────────────────────────────────── await installSampler(); const flight = await run('fly_route', { label: 'cinema evidence', speed: 'normal' }); await page.evaluate(() => window.__gevFlyTrace.marks.push({ label: 'flight-start', t: performance.now() })); report(flight?.ok === true, 'fly_route accepted', `distanceM=${flight?.distanceM} durationS=${flight?.durationS} waypoints=${flight?.waypoints}`); if (!flight?.ok) throw new Error(`fly_route refused: ${flight?.error}`); const coldPath = await page.evaluate(async () => { try { const mod = await import('/src/cameraVerbs.js'); return mod.getActiveCameraMotion?.() ?? null; } catch { return 'module-unavailable'; } }); if (coldPath && coldPath !== 'module-unavailable') { report(true, 'cold-path telemetry read from the live flight', `arming=${coldPath.arming} floorKnown=${coldPath.floorKnown} viaMeshProbe=${coldPath.floorFromMeshProbe}` + (TERRAIN_DELAY_MS ? ` (terrain proxy held back ${TERRAIN_DELAY_MS} ms, ${terrainRequests} request(s))` : '')); } // Liveness is read off the CAMERA, never off a module import: under Vite the // dev server hands a dynamic import its own module instance after any HMR // update, whose motion slot is empty no matter what the app is doing. const stillForMs = () => page.evaluate(() => { const rows = window.__gevFlyTrace.rows; if (rows.length < 3) return 0; const last = rows.at(-1); for (let i = rows.length - 2; i >= 0; i -= 1) { const moved = Math.abs(rows[i].lon - last.lon) + Math.abs(rows[i].lat - last.lat) + (Math.abs(rows[i].height - last.height) / 1e5); if (moved > 1e-7) return last.t - rows[i].t; } return last.t - rows[0].t; }); const budgetMs = Math.min(180000, ((flight.durationS || 40) + 10) * 1000); const shots = []; const startedAt = Date.now(); let shotIndex = 0; while (Date.now() - startedAt < budgetMs) { const file = path.join(OUT_DIR, `flight-${String(shotIndex).padStart(2, '0')}.png`); const at = await page.evaluate(() => performance.now()); await page.screenshot({ path: file }); shots.push({ file, at }); shotIndex += 1; await sleep(SHOT_EVERY_MS); if (Date.now() - startedAt > 5000 && (await stillForMs()) > 1500) break; } const trace = await readTrace(); await page.evaluate(() => window.__gevFlyTraceRemove?.()); report(shots.length >= 6, 'screenshot sequence captured', `${shots.length} frames @ ${SHOT_EVERY_MS}ms`); // ── Measure the REAL camera ─────────────────────────────────────────── // Only the dolly's own frames count. The sampler is installed before the // flight, so the pre-flight view and the single frame that jumps the camera // onto the route start are dropped — neither is dolly motion. const flightStartT = trace.marks.find((m) => m.label === 'flight-start')?.t ?? 0; const rows = trace.rows.filter((r) => r.t > flightStartT).slice(1); const fps = rows.length / Math.max(0.001, (rows.at(-1).t - rows[0].t) / 1000); report(rows.length > 60, 'camera sampled every rendered frame', `${rows.length} dolly samples (${trace.rows.length} total) at ${fps.toFixed(1)} fps`); const rolls = rows.map((r) => wrapDeg(r.rollDeg)); const peakRoll = Math.max(...rolls.map(Math.abs)); report(peakRoll <= 10.5, 'bank never exceeds the 10° cap', `peak |roll| = ${peakRoll.toFixed(2)}°`); report(peakRoll > 3, 'turns actually bank', `peak |roll| = ${peakRoll.toFixed(2)}°`); report(Math.abs(rolls[0]) < 1 && Math.abs(rolls.at(-1)) < 3, 'the flight starts and finishes near wings level', `first=${rolls[0].toFixed(2)}° last=${rolls.at(-1).toFixed(2)}°`); // Roll follows the turn: while banked, roll sign must match heading rate. // Sampled over ~1 s of heading change, and only where BOTH the roll and the // turn are unambiguous (a roll-out trails its turn by design, so the tail of // every corner is deliberately outside the window). let agree = 0; let disagree = 0; const span = Math.max(2, Math.round(fps)); for (let i = span; i < rows.length; i += 1) { const dt = (rows[i].t - rows[i - span].t) / 1000; if (!(dt > 0)) continue; const headingRate = wrapDeg(rows[i].headingDeg - rows[i - span].headingDeg) / dt; const roll = wrapDeg(rows[i].rollDeg); if (Math.abs(roll) < 2 || Math.abs(headingRate) < 3) continue; if (Math.sign(roll) === Math.sign(headingRate)) agree += 1; else disagree += 1; } const agreement = agree / Math.max(1, agree + disagree); report(agreement > 0.85, 'the camera rolls INTO the turn (right turn → right bank)', `${(agreement * 100).toFixed(1)}% of banked samples agree (${agree}/${agree + disagree})`); // Every rate below is measured over ~250 ms windows. A per-frame difference // is dominated by the pairing jitter between the motion tick's own clock and // postRender (a 36 ms frame next to an 8 ms one doubles any per-frame rate), // which measures the harness, not the dolly. const RATE_WINDOW_MS = 250; const peakRate = (valueAt) => { let worst = 0; for (let i = 0, j = 0; i < rows.length; i += 1) { while (j < rows.length - 1 && rows[j].t - rows[i].t < RATE_WINDOW_MS) j += 1; const dt = (rows[j].t - rows[i].t) / 1000; if (dt >= RATE_WINDOW_MS / 2000) worst = Math.max(worst, Math.abs(valueAt(j) - valueAt(i)) / dt); } return worst; }; const peakRollRate = peakRate((i) => rolls[i]); report(peakRollRate < 20, 'the roll enters and exits smoothly, never snaps', `peak roll rate ${peakRollRate.toFixed(1)} °/s over ${RATE_WINDOW_MS} ms`); // Speed: eased at both ends, no step in between. Measured over 400 ms // windows — the per-frame delta is dominated by the pairing jitter between // the motion tick's own clock and postRender, not by the dolly. const cumulative = [0]; for (let i = 1; i < rows.length; i += 1) { cumulative.push(cumulative[i - 1] + sampleDistanceM(rows[i - 1], rows[i])); } const speeds = []; for (let i = 0, j = 0; i < rows.length; i += 1) { while (j < rows.length - 1 && rows[j].t - rows[i].t < 400) j += 1; const dt = (rows[j].t - rows[i].t) / 1000; if (dt >= 0.3) speeds.push({ t: rows[i].t - rows[0].t, v: (cumulative[j] - cumulative[i]) / dt }); } const window1s = (from, to) => { const inWindow = speeds.filter((s) => s.t >= from && s.t <= to); return inWindow.length ? inWindow.reduce((sum, s) => sum + s.v, 0) / inWindow.length : Number.NaN; }; const peakV = Math.max(...speeds.map((s) => s.v)); const firstSecond = window1s(0, 1200); report(firstSecond < peakV * 0.5, 'the dolly eases IN (no velocity step at the start)', `first 1.2 s ${firstSecond.toFixed(1)} m/s vs peak ${peakV.toFixed(1)} m/s`); // Ease-out is measured as the SHAPE of the decay, not as a terminal window. // The sampler keeps running after the dolly stops, so a trailing average // includes stationary frames — under which a hard stop also reports ~0 m/s // and passes. How long the speed takes to fall from 90% to 10% of peak is // immune to that tail: a ramp spreads it over a second or more, a hard stop // collapses it into a single frame. const lastAbove = (fraction) => { for (let i = speeds.length - 1; i >= 0; i -= 1) if (speeds[i].v >= peakV * fraction) return speeds[i].t; return Number.NaN; }; const decayMs = lastAbove(0.1) - lastAbove(0.9); report(decayMs > 800, 'the dolly eases OUT over a real ramp, not a hard stop', `speed fell 90% → 10% of peak over ${decayMs.toFixed(0)} ms (a hard stop collapses to one frame)`); const riseMs = (() => { const first = (fraction) => speeds.find((s) => s.v >= peakV * fraction)?.t ?? Number.NaN; return first(0.9) - first(0.1); })(); report(riseMs > 800, 'and eases IN over one too', `speed rose 10% → 90% of peak over ${riseMs.toFixed(0)} ms`); report(peakV < 40 * 1.35, 'the easing keeps the shipped pace — the plateau IS the speed word', `peak ${peakV.toFixed(1)} m/s over a 40 m/s mean (${(flight.distanceM / flight.durationS).toFixed(1)} m/s reported)`); // Acceleration, differenced across NON-overlapping speed windows so the // 400 ms averaging is not differentiated against itself. let peakAccel = 0; for (let i = 0, j = 0; i < speeds.length; i += 1) { while (j < speeds.length - 1 && speeds[j].t - speeds[i].t < 400) j += 1; const dt = (speeds[j].t - speeds[i].t) / 1000; if (dt >= 0.3) peakAccel = Math.max(peakAccel, Math.abs(speeds[j].v - speeds[i].v) / dt); } report(peakAccel < 40, 'no velocity discontinuity anywhere on the route', `peak |acceleration| ${peakAccel.toFixed(1)} m/s² (a hard start would read in the hundreds)`); // Altitude shaping and terrain clearance, read off the real camera. const heights = rows.map((r) => r.height); const floors = await page.evaluate(async (samples) => { try { const mod = await import('/src/data/groundFloor.js'); return samples.map(({ lat, lon }) => mod.cachedGroundFloor(lat, lon)); } catch { return samples.map(() => null); } }, rows.map((r) => ({ lat: r.lat, lon: r.lon }))); // The strongest terrain check available: the eye against the RENDERED // surface under it, sampled live. Independent of our own floor cache, and // therefore the one that would catch flying inside a building or a hillside. const probed = rows.filter((r) => Number.isFinite(r.surfaceM)); if (probed.length > 20) { let worst = Infinity; let worstAt = null; for (const row of probed) { const clearance = row.height - row.surfaceM; if (clearance < worst) { worst = clearance; worstAt = row; } } report(worst > 0, 'the eye is never inside the RENDERED world', `min clearance over the rendered surface ${worst.toFixed(1)} m across ${probed.length} live probes` + (worstAt ? ` (worst at ${worstAt.lat.toFixed(5)}, ${worstAt.lon.toFixed(5)})` : '')); } else { note('rendered-surface clearance', `only ${probed.length} live mesh probes answered`); } const warm = floors.map((f, i) => (Number.isFinite(f) ? heights[i] - f : null)).filter((v) => v !== null); if (warm.length > 20) { const minAgl = Math.min(...warm); const maxAgl = Math.max(...warm); report(minAgl >= 90, 'the eye always clears the rendered floor', `min AGL ${minAgl.toFixed(1)} m, max ${maxAgl.toFixed(1)} m over ${warm.length} warm samples`); } else { note('terrain clearance', `only ${warm.length} warm floor cells under the route — clearance clamp is pinned in npm test`); } // The floor ACQUISITION — the one frame where a cold-corridor safety seed is // replaced by real terrain — is deliberately a single step, and it lands in // the same moment the camera teleports onto the route start. It is measured // separately from the shaping, which must be a swell for the whole flight. const cruiseFrom = rows.findIndex((r) => r.t - rows[0].t > 1500); const cruiseRows = cruiseFrom > 0 ? rows.slice(cruiseFrom) : rows; const cruiseHeights = cruiseRows.map((r) => r.height); // Split the two directions: a DESCENT is capped by the dolly (never drop the // eye toward ground it is still learning about), while a CLIMB is deliberately // uncapped — rising is the safety direction, and a terrain rise plus the // shaping swell can legitimately exceed the descent cap. let peakDescentMps = 0; let peakClimbMps = 0; for (let i = 0, j = 0; i < cruiseRows.length; i += 1) { while (j < cruiseRows.length - 1 && cruiseRows[j].t - cruiseRows[i].t < RATE_WINDOW_MS) j += 1; const dt = (cruiseRows[j].t - cruiseRows[i].t) / 1000; if (dt < RATE_WINDOW_MS / 2000) continue; const rate = (cruiseHeights[j] - cruiseHeights[i]) / dt; if (rate < 0) peakDescentMps = Math.max(peakDescentMps, -rate); else peakClimbMps = Math.max(peakClimbMps, rate); } const peakVerticalMps = Math.max(peakDescentMps, peakClimbMps); const acquisitionM = Math.abs(heights[0] - cruiseHeights[0]); const cruiseRangeM = Math.max(...cruiseHeights) - Math.min(...cruiseHeights); report(cruiseRangeM > 5, 'altitude breathes rather than sitting flat', `${cruiseRangeM.toFixed(1)} m of vertical range in cruise`); report(cruiseRangeM < 120, 'and the cruise altitude never wanders far from its mean', `${cruiseRangeM.toFixed(1)} m of range after a ${acquisitionM.toFixed(0)} m floor acquisition at the start`); // Smoothness is measured on the camera's ABSOLUTE vertical motion, because // that is what a viewer sees. It is tempting to difference AGL instead, to // separate "our shaping" from "the hill" — but the floor is a ~111 m // staircase, so an AGL series steps at every cell boundary even when the eye // is gliding. Differencing it measures the quantization, not the ride // (measured: 31.6 m/s of "AGL rate" while the eye moved at 8.9 m/s). report(peakDescentMps < 10.5, 'the eye is never DROPPED — descent stays inside its cap', `peak descent ${peakDescentMps.toFixed(1)} m/s (cap 10 m/s)`); report(peakClimbMps < 20, 'and climbs stay a swell rather than a lurch', `peak climb ${peakClimbMps.toFixed(1)} m/s against ~40 m/s of ground speed`); const pitches = rows.map((r) => r.pitchDeg); const pitchSpread = Math.max(...pitches) - Math.min(...pitches); report(pitchSpread < 2, 'the look-down angle stays locked (no pitch wobble)', `pitch ${Math.min(...pitches).toFixed(1)}°..${Math.max(...pitches).toFixed(1)}°`); // ── Run 2: interrupt the dolly MID-BANK ─────────────────────────────── // Cutting a level camera proves nothing about levelling, so this waits for // the live camera to actually be rolled before it grabs the controls. await sleep(1500); await installSampler(); const second = await run('fly_route', { label: 'cinema evidence', speed: 'normal' }); report(second?.ok === true, 'second flight starts for the interrupt case'); const liveRollDeg = () => page.evaluate( () => (window.__godsEyeView.viewer.camera.roll * 180) / Math.PI, ); let rollBeforeCut = 0; for (let waited = 0; waited < 90000; waited += 400) { rollBeforeCut = wrapDeg(await liveRollDeg()); if (Math.abs(rollBeforeCut) >= 3) break; await sleep(400); } report(Math.abs(rollBeforeCut) >= 3, 'the dolly is genuinely banked before the cut', `live camera roll ${rollBeforeCut.toFixed(2)}°`); await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-0-banked.png') }); const cut = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const canvas = viewer.scene.canvas; // Read the motion slot BEFORE the cut too: under Vite a dynamic import can // hand back a second module instance whose slot is always empty, and an // "empty after" that was already empty before proves nothing. let read = null; let slotBefore = 'module-unavailable'; let slotAfter = 'module-unavailable'; try { const mod = await import('/src/cameraVerbs.js'); read = () => mod.getActiveCameraMotion?.() ?? null; slotBefore = read(); } catch { /* dev-only module read */ } const rollBefore = (viewer.camera.roll * 180) / Math.PI; window.__gevFlyTrace.marks.push({ label: 'pointerdown', t: performance.now() }); canvas.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); // Same synchronous turn as the pointerdown — no frame has rendered yet. const rollAfter = (viewer.camera.roll * 180) / Math.PI; if (read) slotAfter = read(); return { slotBefore, slotAfter, rollBefore, rollAfter }; }); // The camera-side proof, which needs no module identity at all: a banked // horizon is level again inside the same synchronous turn as the pointerdown. report( Math.abs(wrapDeg(cut.rollBefore)) >= 3 && Math.abs(wrapDeg(cut.rollAfter)) < 0.01, 'the release levels the horizon synchronously — no tilt left behind', `roll ${wrapDeg(cut.rollBefore).toFixed(2)}° → ${wrapDeg(cut.rollAfter).toFixed(4)}° in the same turn`, ); if (cut.slotBefore && cut.slotBefore !== 'module-unavailable') { report(cut.slotAfter === null, 'a manual pointerdown frees the motion slot synchronously', `active before the cut: ${cut.slotBefore.kind}@${(cut.slotBefore.progress * 100).toFixed(0)}% → after: ${JSON.stringify(cut.slotAfter)}`); } else { note('motion-slot read', 'the dev server handed the harness a second module instance (HMR); the camera-side roll and freeze checks carry the proof'); } await sleep(2500); const rollAfterSettle = wrapDeg(await liveRollDeg()); report(Math.abs(rollAfterSettle) < 0.01, 'and the horizon STAYS level after the cut', `roll ${rollAfterSettle.toFixed(4)}° 2.5 s later`); await page.screenshot({ path: path.join(OUT_DIR, 'interrupt-1-level.png') }); const cutTrace = await readTrace(); await page.evaluate(() => window.__gevFlyTraceRemove?.()); const cutAt = cutTrace.marks.at(-1)?.t ?? 0; const before = cutTrace.rows.filter((r) => r.t < cutAt); const after = cutTrace.rows.filter((r) => r.t >= cutAt); const movementBefore = before.length > 2 ? sampleDistanceM(before.at(-3), before.at(-1)) : Number.NaN; let movementAfter = 0; for (let i = 1; i < after.length; i += 1) { movementAfter = Math.max(movementAfter, sampleDistanceM(after[i - 1], after[i])); } report(movementBefore > 0.5 && movementAfter < 0.5, 'the camera freezes on the cut frame — no coast, no snap-back', `moved ${movementBefore.toFixed(2)} m/frame before, max ${movementAfter.toFixed(3)} m/frame after (${after.length} frames)`); fs.writeFileSync( path.join(OUT_DIR, 'trace.json'), JSON.stringify({ url: APP_URL, flight, shots: shots.map((s) => path.basename(s.file)), samples: rows.length, peakRollDeg: peakRoll, peakRollRateDegS: peakRollRate, rollTurnAgreement: agreement, peakSpeedMps: peakV, peakAccelMps2: peakAccel, peakVerticalMps, peakDescentMps, peakClimbMps, easeInMps: firstSecond, easeInRampMs: riseMs, easeOutRampMs: decayMs, heightRangeM: [Math.min(...heights), Math.max(...heights)], cruiseHeightRangeM: cruiseRangeM, floorAcquisitionM: acquisitionM, pitchRangeDeg: [Math.min(...pitches), Math.max(...pitches)], finalRollDeg: rolls.at(-1), terrainDelayMs: TERRAIN_DELAY_MS, interrupt: { movementBefore, movementAfter, framesAfter: after.length, rollBeforeCutDeg: wrapDeg(cut.rollBefore), rollAfterCutDeg: wrapDeg(cut.rollAfter), rollAfterSettleDeg: rollAfterSettle, }, series: rows.map((r) => ({ t: Number((r.t - rows[0].t).toFixed(0)), roll: Number(wrapDeg(r.rollDeg).toFixed(3)), heading: Number(r.headingDeg.toFixed(2)), pitch: Number(r.pitchDeg.toFixed(2)), height: Number(r.height.toFixed(1)), })), }, null, 2), ); // Contact sheet: the sequence in one image, each tile stamped with the // elapsed time and the roll the camera was actually holding at that moment. const nearestRow = (at) => rows.reduce( (best, row) => (Math.abs(row.t - at) < Math.abs(best.t - at) ? row : best), rows[0], ); const cols = 5; const tileW = 384; const tileH = Math.round((950 / 1500) * tileW); const labelH = 26; const gap = 6; const sheetRows = Math.ceil(shots.length / cols); const sheetW = (cols * tileW) + ((cols + 1) * gap); const sheetH = (sheetRows * (tileH + labelH)) + ((sheetRows + 1) * gap); const composites = []; const labels = []; for (let i = 0; i < shots.length; i += 1) { const col = i % cols; const row = Math.floor(i / cols); const left = gap + (col * (tileW + gap)); const top = gap + (row * (tileH + labelH + gap)); composites.push({ input: await sharp(shots[i].file).resize(tileW, tileH, { fit: 'fill' }).toBuffer(), left, top, }); const near = nearestRow(shots[i].at); const elapsed = ((near.t - rows[0].t) / 1000).toFixed(0); const roll = wrapDeg(near.rollDeg); const sign = roll >= 0 ? '+' : '−'; labels.push(`` + `${String(i).padStart(2, '0')} t=${elapsed}s roll ${sign}${Math.abs(roll).toFixed(1)}° alt ${near.height.toFixed(0)}m`); } composites.push({ input: Buffer.from(`${labels.join('')}`), left: 0, top: 0, }); const sheetPath = path.join(OUT_DIR, 'sequence-contact-sheet.jpg'); await sharp({ create: { width: sheetW, height: sheetH, channels: 3, background: '#05080d' } }) .composite(composites) .jpeg({ quality: 86 }) .toFile(sheetPath); console.log(` contact sheet → ${sheetPath}`); if (MIRROR_DIR) { // Mirror as JPEG: the PNG sequence is ~80 MB, which is a poor thing to // hand a human who just wants to flip through the shot. for (const entry of fs.readdirSync(OUT_DIR)) { const from = path.join(OUT_DIR, entry); if (entry.endsWith('.png')) { await sharp(from).jpeg({ quality: 80 }) .toFile(path.join(MIRROR_DIR, entry.replace(/\.png$/, '.jpg'))); } else { fs.copyFileSync(from, path.join(MIRROR_DIR, entry)); } } } } catch (error) { report(false, 'harness completed', String(error?.message || error).slice(0, 200)); } finally { await browser.close(); } const failed = results.filter((r) => r.ok === false).length; const passed = results.filter((r) => r.ok === true).length; console.log(`\n ${passed} passed, ${failed} failed → ${OUT_DIR}`); process.exitCode = failed ? 1 : 0;