gods-eye-view/scripts/qa-flyroute-cinema.mjs

606 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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(`<text x="${left + 6}" y="${top + tileH + 18}" font-family="monospace" font-size="15" fill="#8fe9ff">`
+ `${String(i).padStart(2, '0')} t=${elapsed}s roll ${sign}${Math.abs(roll).toFixed(1)}° alt ${near.height.toFixed(0)}m</text>`);
}
composites.push({
input: Buffer.from(`<svg width="${sheetW}" height="${sheetH}">${labels.join('')}</svg>`),
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;