722 lines
28 KiB
JavaScript
722 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Moving visual evidence for focus de-emphasis and aircraft recession.
|
|
*
|
|
* The operator owns the live dev server; this script never starts one:
|
|
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
|
* --screenshots-dir qa-shots/focus-evidence \
|
|
* --json qa-shots/focus-evidence/report.json
|
|
* node scripts/qa-focus-evidence.mjs --headful --smoke
|
|
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
|
|
* --params '{"focus":{"dimFloor":0.35},"horizon":{"scaleFloor":0.5}}'
|
|
* node scripts/qa-focus-evidence.mjs --headful --basemap osm
|
|
*
|
|
* The harness owns the Cesium frame clock during capture: every frame applies
|
|
* synthetic updates, advances explicit virtual time, calls scene.render(),
|
|
* then screenshots. Headless mode keeps SwiftShader for CI-relative evidence;
|
|
* --headful drops those flags and is the real-GPU visual sign-off path.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import puppeteer from 'puppeteer';
|
|
|
|
const argv = process.argv.slice(2);
|
|
const getOpt = (name, fallback = null) => {
|
|
const index = argv.indexOf(name);
|
|
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
|
};
|
|
const hasFlag = (name) => argv.includes(name);
|
|
|
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
|
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
|
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
|
const HEADFUL = hasFlag('--headful');
|
|
const SMOKE = hasFlag('--smoke');
|
|
const MAP_STACK_IDS = Object.freeze(['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
|
|
const BASEMAP = getOpt('--basemap', 'photoreal');
|
|
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
|
const FRAME_COUNT = SMOKE ? 6 : 30;
|
|
const FRAME_MS = 100;
|
|
const TILE_SETTLE_TIMEOUT_MS = 45_000;
|
|
const TARGET_ID = 'f0c001';
|
|
const _firstCaptureScenarios = new Set();
|
|
if (!MAP_STACK_IDS.includes(BASEMAP)) {
|
|
throw new Error(`Invalid --basemap ${BASEMAP}; expected one of ${MAP_STACK_IDS.join(', ')}`);
|
|
}
|
|
const TARGET = Object.freeze({
|
|
id: TARGET_ID,
|
|
callsign: 'FOCUS1',
|
|
longitude: -97.7431,
|
|
latitude: 30.2672,
|
|
altitudeM: 3_000,
|
|
klass: 'airliner',
|
|
});
|
|
// Owner's 13:58 horizon composition: a low, east-facing regional view whose
|
|
// distant field sits close to the geometric limb instead of a whole-globe view.
|
|
const HORIZON_CAMERA = Object.freeze([
|
|
-100, 30, 1_000_000, Math.PI / 2, -0.52,
|
|
]);
|
|
|
|
function parseParams() {
|
|
const raw = getOpt('--params');
|
|
if (!raw) return { focus: {}, horizon: {} };
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(`Invalid --params JSON: ${error.message}`);
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('--params must be a JSON object');
|
|
}
|
|
if (parsed.focus || parsed.horizon) {
|
|
if ((parsed.focus && (typeof parsed.focus !== 'object' || Array.isArray(parsed.focus)))
|
|
|| (parsed.horizon && (typeof parsed.horizon !== 'object' || Array.isArray(parsed.horizon)))) {
|
|
throw new Error('--params focus and horizon values must be JSON objects');
|
|
}
|
|
return { focus: parsed.focus || {}, horizon: parsed.horizon || {} };
|
|
}
|
|
const focusKeys = new Set([
|
|
'paddingPx', 'dimFloor', 'nearerBehavior', 'hysteresisPx',
|
|
'distanceHysteresisRatio', 'attackMs', 'releaseMs', 'writeEpsilon',
|
|
]);
|
|
const horizonKeys = new Set([
|
|
'startLimbRatio', 'scaleFloor', 'alphaFloor', 'combinedAlphaFloor',
|
|
'globeViewBlendStartM', 'globeViewBlendEndM', 'globeViewHeightM',
|
|
'earthRadiusM', 'writeEpsilon',
|
|
]);
|
|
const focus = {};
|
|
const horizon = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (focusKeys.has(key)) focus[key] = value;
|
|
if (horizonKeys.has(key)) horizon[key] = value;
|
|
}
|
|
return { focus, horizon };
|
|
}
|
|
|
|
function add(a, b) {
|
|
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
}
|
|
|
|
function scale(v, amount) {
|
|
return [v[0] * amount, v[1] * amount, v[2] * amount];
|
|
}
|
|
|
|
function screenPlanePosition(basis, xPx, yPx, fartherM = 0) {
|
|
return add(
|
|
add(
|
|
add(basis.target, scale(basis.right, xPx * basis.metresPerPixel)),
|
|
scale(basis.up, yPx * basis.metresPerPixel),
|
|
),
|
|
scale(basis.away, fartherM),
|
|
);
|
|
}
|
|
|
|
function shotPath(scenario, frame) {
|
|
return path.join(SCREENSHOTS_DIR, `${scenario}-${frame}.png`);
|
|
}
|
|
|
|
async function readTileReadiness(page) {
|
|
return page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const controller = gev.mapStackController;
|
|
const activeStack = controller?.getActiveId?.() || null;
|
|
const tileset = controller?.googleTileset || gev.tileset || null;
|
|
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
|
return {
|
|
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
|
tileSettleApplicable: applicable,
|
|
activeStack,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function awaitTilesSettled(page, scenario) {
|
|
const result = await page.evaluate(async (timeoutMs) => {
|
|
const gev = window.__godsEyeView;
|
|
const controller = gev.mapStackController;
|
|
const activeStack = controller?.getActiveId?.() || null;
|
|
// The map-stack controller owns the authoritative Google tileset handle;
|
|
// the bootstrap field is retained only as a compatibility fallback.
|
|
const tileset = controller?.googleTileset || gev.tileset || null;
|
|
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
|
if (!applicable) {
|
|
return {
|
|
tilesSettled: true,
|
|
tileSettleApplicable: false,
|
|
activeStack,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
if (tileset.tilesLoaded === true) {
|
|
return {
|
|
tilesSettled: true,
|
|
tileSettleApplicable: true,
|
|
activeStack,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
let allTilesLoadedObserved = false;
|
|
const removeListener = tileset.allTilesLoaded?.addEventListener?.(() => {
|
|
allTilesLoadedObserved = true;
|
|
});
|
|
const deadline = Date.now() + timeoutMs;
|
|
try {
|
|
while (Date.now() < deadline) {
|
|
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
|
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
|
return {
|
|
tilesSettled: true,
|
|
tileSettleApplicable: true,
|
|
activeStack,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
return {
|
|
tilesSettled: false,
|
|
tileSettleApplicable: true,
|
|
activeStack,
|
|
timedOut: true,
|
|
};
|
|
} finally {
|
|
if (typeof removeListener === 'function') removeListener();
|
|
}
|
|
}, TILE_SETTLE_TIMEOUT_MS);
|
|
const status = result.tilesSettled ? 'settled' : `NOT settled after ${TILE_SETTLE_TIMEOUT_MS / 1000}s`;
|
|
console.log(` tiles (${scenario}): ${status}`);
|
|
return result;
|
|
}
|
|
|
|
async function capture(page, scenario, frame) {
|
|
if (!_firstCaptureScenarios.has(scenario)) {
|
|
_firstCaptureScenarios.add(scenario);
|
|
await awaitTilesSettled(page, scenario);
|
|
}
|
|
const tileReadiness = await readTileReadiness(page);
|
|
const out = shotPath(scenario, frame);
|
|
// Chromium's Page.captureScreenshot can wedge while a manually driven
|
|
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
|
// that same frame without handing frame ownership back to the browser.
|
|
const dataUrl = await page.evaluate(() => (
|
|
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
|
));
|
|
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
|
return { file: out, ...tileReadiness };
|
|
}
|
|
|
|
async function captureSequenceFrame(page, scenario, frameIndex, update) {
|
|
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: update`);
|
|
await update();
|
|
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: render`);
|
|
await advanceEvidenceFrame(page, FRAME_MS);
|
|
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: screenshot`);
|
|
return capture(page, scenario, `frame-${String(frameIndex).padStart(2, '0')}`);
|
|
}
|
|
|
|
async function takeFrameClock(page) {
|
|
const result = await page.evaluate(() => {
|
|
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
|
return seam.takeFrameClock();
|
|
});
|
|
if (!result?.ok) throw new Error('Unable to take ownership of the evidence frame clock');
|
|
}
|
|
|
|
async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
|
return page.evaluate((stepMs) => {
|
|
const gev = window.__godsEyeView;
|
|
const viewer = gev.viewer;
|
|
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
|
const nowMs = seam.advanceFrameClock(stepMs);
|
|
const JulianDate = viewer.clock.currentTime.constructor;
|
|
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
|
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
|
viewer.scene.render(viewer.clock.currentTime);
|
|
// Cross a task boundary so Chrome can commit the explicitly rendered
|
|
// surface before capture. Cesium's drained default loop stays disabled.
|
|
return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0));
|
|
}, deltaMs);
|
|
}
|
|
|
|
async function advanceEvidenceDuration(page, durationMs) {
|
|
// Cross one 80 ms consumer quantum to publish newly changed desired state,
|
|
// then jump to the requested endpoint. A zero-delta render is insufficient:
|
|
// the production focus passes correctly retain their cadence gate.
|
|
const totalMs = Math.max(0, durationMs);
|
|
const publishMs = Math.min(80, totalMs);
|
|
await advanceEvidenceFrame(page, publishMs);
|
|
if (totalMs > publishMs) await advanceEvidenceFrame(page, totalMs - publishMs);
|
|
}
|
|
|
|
async function releaseFrameClock(page) {
|
|
await page.evaluate(() => {
|
|
window.__godsEyeView.dataManager.layers
|
|
.get('flights').module.__focusEvidence.releaseFrameClock();
|
|
});
|
|
}
|
|
|
|
async function installSyntheticFetches(page) {
|
|
await page.evaluateOnNewDocument(() => {
|
|
const realFetch = window.fetch.bind(window);
|
|
const json = (body) => Promise.resolve(new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
window.fetch = (input, init) => {
|
|
const url = typeof input === 'string' ? input : input?.url || '';
|
|
if (url.includes('/api/opensky-track')) return json({ path: [] });
|
|
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
|
|
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
|
|
if (url.includes('/api/adsbdb/')) return json({ found: false });
|
|
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
|
|
return realFetch(input, init);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function waitForApp(page) {
|
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
await page.waitForFunction(
|
|
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
|
{ timeout: 60_000, polling: 200 },
|
|
);
|
|
}
|
|
|
|
async function setBasemap(page, stackId) {
|
|
const result = await page.evaluate(async (id) => (
|
|
window.__godsEyeView.styleManager.setMapStack(id)
|
|
), stackId);
|
|
if (!result?.ok) {
|
|
throw new Error(`Unable to activate --basemap ${stackId}: ${result?.error || 'unknown error'}`);
|
|
}
|
|
return result.activeStack;
|
|
}
|
|
|
|
async function enableLayer(page, layerId) {
|
|
await page.evaluate(async (id) => {
|
|
await window.__godsEyeView.dataManager.setEnabled(id, true);
|
|
}, layerId);
|
|
}
|
|
|
|
async function requireEvidenceSeams(page) {
|
|
const result = await page.evaluate(() => {
|
|
const manager = window.__godsEyeView.dataManager;
|
|
return {
|
|
flights: Boolean(manager.layers.get('flights')?.module?.__focusEvidence),
|
|
vessels: Boolean(manager.layers.get('ais-live-vessels')?.module?.__focusEvidence),
|
|
};
|
|
});
|
|
if (!result.flights || !result.vessels) {
|
|
throw new Error('Focus evidence seams are unavailable. Use the Vite dev server (not a production preview/build).');
|
|
}
|
|
}
|
|
|
|
async function setCamera(page, values) {
|
|
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
|
const viewer = window.__godsEyeView.viewer;
|
|
const ellipsoid = viewer.scene.globe.ellipsoid;
|
|
viewer.trackedEntity = undefined;
|
|
viewer.camera.cancelFlight?.();
|
|
viewer.scene.tweens?.removeAll?.();
|
|
viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({
|
|
longitude: lon * Math.PI / 180,
|
|
latitude: lat * Math.PI / 180,
|
|
height,
|
|
}),
|
|
orientation: { heading, pitch, roll: 0 },
|
|
});
|
|
}, values);
|
|
await advanceEvidenceDuration(page, 300);
|
|
}
|
|
|
|
async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
|
const result = await page.evaluate(({ targetRecord, extras }) => {
|
|
const layer = window.__godsEyeView.dataManager.layers.get('flights').module;
|
|
const injected = layer.__focusEvidence.setAircraft([targetRecord, ...extras]);
|
|
const tracked = layer.trackById(targetRecord.id);
|
|
return { injected, tracked };
|
|
}, { targetRecord: target, extras: extraAircraft });
|
|
if (!result.injected?.ok || !result.tracked) {
|
|
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
|
}
|
|
await page.waitForFunction(
|
|
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
|
{ timeout: 10_000 },
|
|
);
|
|
await advanceEvidenceDuration(page, 400);
|
|
}
|
|
|
|
async function readTrackedBasis(page) {
|
|
return page.evaluate((targetId) => {
|
|
const gev = window.__godsEyeView;
|
|
const layer = gev.dataManager.layers.get('flights').module;
|
|
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
|
const camera = gev.viewer.camera;
|
|
if (!target) throw new Error('Tracked target position unavailable');
|
|
const toTarget = {
|
|
x: target.x - camera.positionWC.x,
|
|
y: target.y - camera.positionWC.y,
|
|
z: target.z - camera.positionWC.z,
|
|
};
|
|
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
|
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
|
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
|
return {
|
|
target: [target.x, target.y, target.z],
|
|
right: [camera.rightWC.x, camera.rightWC.y, camera.rightWC.z],
|
|
up: [camera.upWC.x, camera.upWC.y, camera.upWC.z],
|
|
away: [toTarget.x / distance, toTarget.y / distance, toTarget.z / distance],
|
|
metresPerPixel,
|
|
targetDistanceM: distance,
|
|
};
|
|
}, TARGET_ID);
|
|
}
|
|
|
|
async function flightSnapshot(page) {
|
|
return page.evaluate(() => (
|
|
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.snapshot()
|
|
));
|
|
}
|
|
|
|
async function setTuning(page, params) {
|
|
return page.evaluate((next) => (
|
|
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.setTuning(next)
|
|
), params);
|
|
}
|
|
|
|
async function runControlledCrossing(page, effectiveParams) {
|
|
const scenario = 's1-controlled-crossing';
|
|
if (SMOKE) console.log(' setup: inject + track');
|
|
await injectAndTrackTarget(page, [{ ...TARGET, id: 'f0c002', callsign: 'CROSS2' }]);
|
|
if (SMOKE) console.log(' setup: read tracked basis');
|
|
const basis = await readTrackedBasis(page);
|
|
// Stay beyond the default 8% range-side hysteresis band so S1 exercises
|
|
// the farther-contact dim path instead of hovering in its neutral band.
|
|
const crossingDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
|
const start = screenPlanePosition(basis, -180, 0, crossingDepthM);
|
|
await page.evaluate((position) => {
|
|
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
|
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
|
}, start);
|
|
// The placeholder begins at the target so tracking can establish its camera
|
|
// first. Restore fully off-target before frame 0; otherwise the sequence
|
|
// would start with inherited dimming rather than showing a true attack.
|
|
if (SMOKE) console.log(' setup: settle off-target contact');
|
|
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
|
const frames = [];
|
|
for (let i = 0; i < FRAME_COUNT; i += 1) {
|
|
const xPx = -180 + (360 * i) / (FRAME_COUNT - 1);
|
|
const cartesian = screenPlanePosition(basis, xPx, 0, crossingDepthM);
|
|
const captured = await captureSequenceFrame(page, scenario, i, () => page.evaluate((position) => {
|
|
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
|
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
|
|
}, cartesian));
|
|
const snapshot = await flightSnapshot(page);
|
|
frames.push({ index: i, xPx, ...captured, contacts: snapshot });
|
|
}
|
|
return { id: scenario, frameCount: frames.length, basis, crossingDepthM, frames };
|
|
}
|
|
|
|
async function vesselRowsAroundTarget(page) {
|
|
return page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const layer = gev.dataManager.layers.get('flights').module;
|
|
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
|
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
|
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
|
const rows = [];
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const angle = (i / 12) * Math.PI * 2;
|
|
const eastM = Math.cos(angle) * (15 + (i % 3) * 12);
|
|
const northM = Math.sin(angle) * (15 + (i % 3) * 12);
|
|
// Cesium.Transforms is not global; a small local lat/lon offset is
|
|
// sufficient at harbor scale and keeps the injected vessel at sea level.
|
|
const carto = Cartographic.fromCartesian(target, ellipsoid);
|
|
const lat = carto.latitude * 180 / Math.PI + northM / 111_320;
|
|
const lon = carto.longitude * 180 / Math.PI
|
|
+ eastM / (111_320 * Math.cos(carto.latitude));
|
|
rows.push({
|
|
mmsi: String(990000000 + i),
|
|
name: `HARBOR ${String(i + 1).padStart(2, '0')}`,
|
|
lat,
|
|
lon,
|
|
speed: 4 + (i % 5),
|
|
course: (i * 31) % 360,
|
|
type: i % 3 === 0 ? 'Cargo' : 'Tug',
|
|
});
|
|
}
|
|
return rows;
|
|
});
|
|
}
|
|
|
|
async function runHarborClutter(page, effectiveParams) {
|
|
const scenario = 's2-harbor-clutter';
|
|
const lowTarget = { ...TARGET, altitudeM: 50 };
|
|
// Harbor contacts straddle the tracked low-altitude subject's range. This
|
|
// scenario deliberately exercises the documented tunable all-overlap policy
|
|
// instead of being neutralized by the production `allow` range band.
|
|
const clutterFocus = { ...effectiveParams.focus, nearerBehavior: 'dim' };
|
|
await injectAndTrackTarget(page, [], lowTarget);
|
|
const rows = await vesselRowsAroundTarget(page);
|
|
await page.evaluate((vessels) => {
|
|
const seam = window.__godsEyeView.dataManager.layers
|
|
.get('ais-live-vessels').module.__focusEvidence;
|
|
seam.setVessels(vessels);
|
|
}, rows);
|
|
|
|
await setTuning(page, { focus: { ...clutterFocus, dimFloor: 1 } });
|
|
await advanceEvidenceDuration(page, 450);
|
|
const beforeCapture = await capture(page, scenario, 'before');
|
|
const before = await page.evaluate(() => (
|
|
window.__godsEyeView.dataManager.layers
|
|
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
|
));
|
|
|
|
await setTuning(page, { ...effectiveParams, focus: clutterFocus });
|
|
await advanceEvidenceDuration(page, Math.max(500, effectiveParams.focus.attackMs + 200));
|
|
const afterCapture = await capture(page, scenario, 'after');
|
|
const after = await page.evaluate(() => (
|
|
window.__godsEyeView.dataManager.layers
|
|
.get('ais-live-vessels').module.__focusEvidence.snapshot()
|
|
));
|
|
await setTuning(page, effectiveParams);
|
|
return {
|
|
id: scenario,
|
|
focusParams: clutterFocus,
|
|
frames: [
|
|
{ phase: 'before', ...beforeCapture, contacts: before },
|
|
{ phase: 'after', ...afterCapture, contacts: after },
|
|
],
|
|
};
|
|
}
|
|
|
|
async function runAirportTraffic(page, effectiveParams) {
|
|
const scenario = 's3-airport-traffic';
|
|
const placeholders = Array.from({ length: 40 }, (_, index) => ({
|
|
...TARGET,
|
|
id: `f3${String(index).padStart(4, '0')}`,
|
|
callsign: `APT${String(index).padStart(2, '0')}`,
|
|
klass: index % 7 === 0 ? 'widebody' : index % 5 === 0 ? 'fastjet' : 'airliner',
|
|
}));
|
|
await injectAndTrackTarget(page, placeholders);
|
|
const basis = await readTrackedBasis(page);
|
|
const trafficDepthM = Math.max(600, basis.targetDistanceM * 0.12);
|
|
const startingMoves = placeholders.map((record, index) => {
|
|
const angle = (index / placeholders.length) * Math.PI * 2;
|
|
return {
|
|
id: record.id,
|
|
cartesian: screenPlanePosition(
|
|
basis,
|
|
Math.cos(angle) * 220,
|
|
Math.sin(angle) * 220 * 0.55,
|
|
trafficDepthM + index * 3,
|
|
),
|
|
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
|
};
|
|
});
|
|
await page.evaluate((positions) => {
|
|
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
|
seam.moveAircraft(positions);
|
|
}, startingMoves);
|
|
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
|
|
const frames = [];
|
|
for (let frame = 0; frame < FRAME_COUNT; frame += 1) {
|
|
const progress = frame / (FRAME_COUNT - 1);
|
|
const radiusPx = 220 - 205 * progress;
|
|
const moves = placeholders.map((record, index) => {
|
|
const angle = (index / placeholders.length) * Math.PI * 2 + progress * 0.35;
|
|
return {
|
|
id: record.id,
|
|
cartesian: screenPlanePosition(
|
|
basis,
|
|
Math.cos(angle) * radiusPx,
|
|
Math.sin(angle) * radiusPx * 0.55,
|
|
trafficDepthM + index * 3,
|
|
),
|
|
trackDeg: (angle * 180 / Math.PI + 180) % 360,
|
|
};
|
|
});
|
|
const captured = await captureSequenceFrame(page, scenario, frame, () => page.evaluate((positions) => {
|
|
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
|
|
seam.moveAircraft(positions);
|
|
}, moves));
|
|
frames.push({ index: frame, radiusPx, ...captured, contacts: await flightSnapshot(page) });
|
|
}
|
|
return { id: scenario, frameCount: frames.length, basis, frames };
|
|
}
|
|
|
|
async function runHorizonBand(page, effectiveParams) {
|
|
const scenario = 's4-horizon-band';
|
|
await page.evaluate(() => {
|
|
window.__godsEyeView.dataManager.layers.get('flights').module.stopTracking();
|
|
});
|
|
const contacts = [];
|
|
for (let row = 0; row < 6; row += 1) {
|
|
for (let column = 0; column < 10; column += 1) {
|
|
contacts.push({
|
|
id: `f4${String(row * 10 + column).padStart(4, '0')}`,
|
|
callsign: `LIMB${String(row * 10 + column).padStart(2, '0')}`,
|
|
longitude: -70.5 + column * 0.42,
|
|
latitude: 27.5 + row * 0.95,
|
|
altitudeM: 10_000 + (column % 3) * 800,
|
|
klass: column % 4 === 0 ? 'widebody' : 'airliner',
|
|
});
|
|
}
|
|
}
|
|
await page.evaluate((records) => {
|
|
window.__godsEyeView.dataManager.layers.get('flights')
|
|
.module.__focusEvidence.setAircraft(records);
|
|
}, contacts);
|
|
await setCamera(page, HORIZON_CAMERA);
|
|
|
|
await setTuning(page, {
|
|
horizon: { ...effectiveParams.horizon, scaleFloor: 1, alphaFloor: 1 },
|
|
});
|
|
await advanceEvidenceDuration(page, 450);
|
|
const beforeCapture = await capture(page, scenario, 'before');
|
|
const before = await flightSnapshot(page);
|
|
|
|
await setTuning(page, effectiveParams);
|
|
await advanceEvidenceDuration(page, 450);
|
|
const afterCapture = await capture(page, scenario, 'after');
|
|
const after = await flightSnapshot(page);
|
|
return {
|
|
id: scenario,
|
|
camera: HORIZON_CAMERA,
|
|
frames: [
|
|
{ phase: 'before', ...beforeCapture, contacts: before },
|
|
{ phase: 'after', ...afterCapture, contacts: after },
|
|
],
|
|
};
|
|
}
|
|
|
|
async function browserContext(page, browser) {
|
|
const gl = await page.evaluate(() => {
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
const debug = context?.getExtension('WEBGL_debug_renderer_info');
|
|
return {
|
|
userAgent: navigator.userAgent,
|
|
dpr: window.devicePixelRatio,
|
|
vendor: debug
|
|
? context.getParameter(debug.UNMASKED_VENDOR_WEBGL)
|
|
: context?.getParameter(context.VENDOR) || 'unavailable',
|
|
renderer: debug
|
|
? context.getParameter(debug.UNMASKED_RENDERER_WEBGL)
|
|
: context?.getParameter(context.RENDERER) || 'unavailable',
|
|
};
|
|
});
|
|
return {
|
|
browser: await browser.version(),
|
|
userAgent: gl.userAgent,
|
|
webglVendor: gl.vendor,
|
|
webglRenderer: gl.renderer,
|
|
viewport: { ...VIEWPORT, dpr: gl.dpr },
|
|
machine: {
|
|
hostname: os.hostname(),
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
},
|
|
caveat: HEADFUL
|
|
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
|
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const requestedParams = parseParams();
|
|
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
|
if (!response.ok) {
|
|
throw new Error(`Live dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
|
}
|
|
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
fs.mkdirSync(path.dirname(JSON_PATH), { recursive: true });
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
protocolTimeout: 300000,
|
|
args: [
|
|
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
|
],
|
|
});
|
|
const page = await browser.newPage();
|
|
await page.setViewport(VIEWPORT);
|
|
const consoleMessages = [];
|
|
page.on('console', (message) => {
|
|
if (['error', 'warning', 'warn'].includes(message.type())) {
|
|
consoleMessages.push({ type: message.type(), text: message.text() });
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
|
|
|
const report = {
|
|
generatedAt: new Date().toISOString(),
|
|
url: APP_URL,
|
|
screenshotsDir: SCREENSHOTS_DIR,
|
|
requestedParams,
|
|
requestedBasemap: BASEMAP,
|
|
activeBasemap: null,
|
|
smoke: SMOKE,
|
|
effectiveParams: null,
|
|
context: null,
|
|
scenarios: [],
|
|
consoleMessages,
|
|
};
|
|
try {
|
|
await installSyntheticFetches(page);
|
|
await waitForApp(page);
|
|
report.activeBasemap = await setBasemap(page, BASEMAP);
|
|
await enableLayer(page, 'flights');
|
|
await enableLayer(page, 'ais-live-vessels');
|
|
await requireEvidenceSeams(page);
|
|
await takeFrameClock(page);
|
|
report.context = await browserContext(page, browser);
|
|
report.effectiveParams = await setTuning(page, requestedParams);
|
|
|
|
console.log(`Focus evidence → ${APP_URL}`);
|
|
console.log(` screenshots: ${SCREENSHOTS_DIR}`);
|
|
console.log(` renderer : ${report.context.webglRenderer}`);
|
|
console.log(` basemap : ${report.activeBasemap}`);
|
|
console.log(` params : ${JSON.stringify(report.effectiveParams)}`);
|
|
console.log(` mode : ${SMOKE ? 'smoke (S1, 6 frames)' : 'full (S1-S4)'}`);
|
|
console.log(` caveat : ${report.context.caveat}`);
|
|
|
|
const scenarios = [
|
|
['S1 controlled crossing', () => runControlledCrossing(page, report.effectiveParams)],
|
|
['S2 harbor clutter', () => runHarborClutter(page, report.effectiveParams)],
|
|
['S3 airport traffic', () => runAirportTraffic(page, report.effectiveParams)],
|
|
['S4 horizon band', () => runHorizonBand(page, report.effectiveParams)],
|
|
];
|
|
for (const [label, run] of (SMOKE ? scenarios.slice(0, 1) : scenarios)) {
|
|
console.log(` capture : ${label}`);
|
|
await page.bringToFront();
|
|
report.scenarios.push(await run());
|
|
}
|
|
} finally {
|
|
if (!page.isClosed()) await releaseFrameClock(page).catch(() => {});
|
|
await browser.close();
|
|
}
|
|
|
|
fs.writeFileSync(JSON_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(` report : ${JSON_PATH}`);
|
|
if (consoleMessages.some((message) => message.type === 'error' || message.type === 'pageerror')) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error?.stack || error);
|
|
process.exit(1);
|
|
});
|