Files
gods-eye-view/scripts/qa-director-camera.mjs

211 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
/** Verify imported camera directions through real rendering and camera authority handoffs. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const output = path.resolve('qa-shots/director-camera');
fs.mkdirSync(output, { recursive: true });
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let failed = 0;
function check(name, passed) {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failed++;
}
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.sceneDirector &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
const result = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const file = {
version: 4,
scenes: [
{
id: 'directions',
title: 'Camera directions',
anchors: [
{
id: 'start',
title: 'Austin west',
lat: 30.2672,
lon: -97.76,
alt: 6000,
altitudeReference: 'ellipsoid',
},
{
id: 'end',
title: 'Austin east',
lat: 30.2672,
lon: -97.73,
alt: 7000,
altitudeReference: 'ellipsoid',
},
],
shots: [
{
id: 'move',
title: 'Across Austin',
durationSec: 1,
holdSec: 0.2,
camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 },
move: {
from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 },
easing: 'linear',
},
layers: {},
visual: { style: 'normal' },
},
],
},
],
};
await d.importProjectFile(
new File([JSON.stringify(file)], 'directions.json'),
);
const scene = d._project.scenes[0];
const shot = scene.shots[0];
const imported =
scene.anchors.length === 2 && shot.camera.anchorId === 'end';
await d.seekScene(scene.id, 0.5 / 1.2);
const middle = style.getCameraState();
const seekMidpoint =
Math.abs(middle.lon - -97.745) < 1e-6 &&
Math.abs(middle.alt - 6500) < 0.01;
await d.seekScene(scene.id, 0);
const back = style.getCameraState();
const backward = Math.abs(back.lon - -97.76) < 1e-6;
const replay = await d.replayShot(scene.id, shot.id);
const end = style.getCameraState();
const replayEnd =
replay?.started &&
Math.abs(end.lon - -97.73) < 1e-6 &&
Math.abs(end.alt - 7000) < 0.01;
d.stopScene();
await d.startScene(scene.id, { single: true, preview: false });
const completed =
!d.running && d.getPlaybackTimingState().activeTimers === 0;
const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6;
// Updating the target creates an explicit inline pose while retaining the authored start.
d.updateSelectedShot();
const saved = JSON.parse(
localStorage.getItem('godsEyeView.sceneProject.v2'),
);
const updated =
saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' &&
saved.scenes[0].shots[0].move.from.anchorId === 'start';
shot.durationSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
const started = d._cameraMotion.active;
style.runImmediateNavigation('location', () => true);
await run;
const revoked =
started &&
!d.running &&
!d._cameraMotion.active &&
d.getPlaybackTimingState().activeTimers === 0;
const stoppedPose = style.getCameraState();
await new Promise((r) => setTimeout(r, 150));
const stable =
Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7;
return {
imported,
seekMidpoint,
backward,
replayEnd,
completed,
settled,
updated,
revoked,
stable,
};
});
for (const [name, passed] of Object.entries(result)) check(name, passed);
await new Promise((r) => setTimeout(r, 6000));
await page.screenshot({ path: path.join(output, 'austin.png') });
await page.evaluate(() => {
const d = window.__godsEyeView.sceneDirector;
const pose = d.styleManager.getCameraState();
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
d.viewer.scene.requestRender();
});
await new Promise((r) => setTimeout(r, 3000));
await page.screenshot({ path: path.join(output, 'angle.png') });
const inputStarted = await page.evaluate(async () => {
const d = window.__godsEyeView.sceneDirector;
const scene = d._project.scenes[0];
window.__qaCameraRun = d.startScene(scene.id, {
single: true,
preview: false,
});
const deadline = Date.now() + 10000;
while (!d._cameraMotion.active && Date.now() < deadline)
await new Promise((r) => setTimeout(r, 20));
return d._cameraMotion.active;
});
const canvas =
(await page.$('canvas.cesium-widget-canvas')) ||
(await page.$('.cesium-widget canvas'));
const rect = await canvas.boundingBox();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.mouse.down();
await page.mouse.up();
const input =
inputStarted &&
(await page.evaluate(async () => {
await window.__qaCameraRun;
const d = window.__godsEyeView.sceneDirector;
return !d.running && d.getPlaybackTimingState().activeTimers === 0;
}));
check('manual canvas input revokes the move and releases clocks', input);
const hold = await page.evaluate(async () => {
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
const scene = d._project.scenes[0];
const shot = scene.shots[0];
shot.durationSec = 0.2;
shot.holdSec = 30;
const run = d.startScene(scene.id, { single: true, preview: false });
const deadline = Date.now() + 10000;
while (
(!d._activeSceneTravel || !d.running || d._cameraMotion.active) &&
Date.now() < deadline
)
await new Promise((r) => setTimeout(r, 20));
const holding = d.running && !d._cameraMotion.active;
const began = performance.now();
style.runImmediateNavigation('location', () => true);
await run;
return (
holding &&
performance.now() - began < 1000 &&
d.getPlaybackTimingState().activeTimers === 0
);
});
check('navigation during an authored hold settles immediately', hold);
if (errors.length)
console.error(
errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')),
);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
if (failed) process.exitCode = 1;