mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
SCENES > VCAM pairs a phone through a QR code and turns it into a handheld camera, after Unreal's Live Link VCAM and Blender's VirtuCamera: - Android Chrome tracks 6DoF through WebXR immersive-ar; iPhone Safari, which has no WebXR AR, tracks rotation through DeviceOrientation, and thumb sticks dolly, truck, crane and pan. - A motion scale (1:1 walk to 1:10000 orbit), smoothing, height and horizon locks, hold/clutch and recenter, none of which make the camera jump. Focal length and scale-aware depth of field with tap-to-focus. - The phone shows the app's render as a monitor (newest-frame JPEG pull). - REC records a take as a version-7 track shot in the selected scene. - The rig claims the camera through the navigation seam (Cockpit refuses it) and holds on any other navigation, scene run or globe input. Pairing never exposes the app: a loopback-only session request starts a separate HTTPS listener (VCAM_PORT, default 4174) that serves only the static phone page and a token-paired relay, and stops after the last session. Its certificate comes from a local CA built with node:crypto, which an iPhone can trust once; without it the phone falls back from WebSocket to HTTPS requests, which iOS allows past a certificate warning. scripts/qa-vcam.mjs drives it end to end: the real phone page over the HTTPS fallback, a scripted 6DoF phone, a recorded and replayed take, depth of field and camera handoff. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015H1iLusTzWpVKitGic2AW8
366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* qa-vcam.mjs — phone virtual camera, end to end.
|
|
*
|
|
* Drives the real desktop app and the real phone page (served by the HTTPS
|
|
* phone listener) in one browser, plus a scripted 6DoF phone over the same
|
|
* relay:
|
|
*
|
|
* 1. SCENES ▸ VCAM starts a session and draws a QR code for the pairing URL.
|
|
* 2. The phone page pairs from the URL fragment, falls back to HTTPS requests
|
|
* when WebSockets are refused (as iOS does under a certificate warning),
|
|
* starts the gyro camera and turns the desktop camera with device
|
|
* orientation; the desktop's render arrives on the phone's monitor.
|
|
* 3. The phone's lens preset and REC button reach the desktop; the recorded
|
|
* take becomes a validated track shot in the selected scene and replays.
|
|
* 4. A scripted 6DoF phone moves one metre at 1:100 and the camera moves
|
|
* about a hundred metres; desktop navigation holds the rig.
|
|
*
|
|
* Needs the dev server (default http://localhost:4173, or QA_BASE_URL) and
|
|
* its phone listener on VCAM_PORT (default 4174), reachable on loopback.
|
|
* Screenshots go to qa-shots/vcam/.
|
|
*
|
|
* npm run dev # in another terminal
|
|
* node scripts/qa-vcam.mjs
|
|
*/
|
|
import fs from 'node:fs';
|
|
import https from 'node:https';
|
|
import path from 'node:path';
|
|
import puppeteer from 'puppeteer';
|
|
import WebSocket from 'ws';
|
|
|
|
const BASE = process.env.QA_BASE_URL || 'http://localhost:4173';
|
|
const output = path.resolve('qa-shots/vcam');
|
|
fs.mkdirSync(output, { recursive: true });
|
|
let failed = 0;
|
|
function check(name, passed, detail = '') {
|
|
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
|
if (!passed) failed++;
|
|
}
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
// Separate browsers, like separate devices: a background tab would hide the
|
|
// desktop page, which suspends its render loop.
|
|
const launch = () =>
|
|
puppeteer.launch({
|
|
headless: true,
|
|
acceptInsecureCerts: true,
|
|
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
args: ['--no-sandbox', '--disable-dev-shm-usage', '--ignore-certificate-errors'],
|
|
});
|
|
const browser = await launch();
|
|
const phoneBrowser = await launch();
|
|
const errors = [];
|
|
try {
|
|
const desk = await browser.newPage();
|
|
desk.on('pageerror', (error) => errors.push(`desktop: ${error.message}`));
|
|
if (process.env.QA_VCAM_DEBUG) desk.on('pageerror', (error) => console.log('DESKTOP ERROR', error.stack || error.message));
|
|
await desk.setViewport({ width: 1280, height: 800 });
|
|
await desk.goto(`${BASE}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
|
await desk.waitForFunction(
|
|
() =>
|
|
window.__godsEyeView?.vcam &&
|
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
{ timeout: 90000 },
|
|
);
|
|
// A shader that fails to compile stops Cesium's render loop.
|
|
await desk.evaluate(() => {
|
|
window.__vcamRenderErrors = [];
|
|
window.__godsEyeView.viewer.scene.renderError.addEventListener((_scene, error) =>
|
|
window.__vcamRenderErrors.push(String(error?.message || error)),
|
|
);
|
|
});
|
|
// Start from a known city view.
|
|
await desk.evaluate(() =>
|
|
window.__godsEyeView.styleManager.applyCameraState(
|
|
{ lat: 30.2672, lon: -97.7431, alt: 1200, heading: 0, pitch: -30, roll: 0 },
|
|
0.2,
|
|
),
|
|
);
|
|
await sleep(800);
|
|
|
|
// 1. Pairing UI.
|
|
await desk.evaluate(() => {
|
|
const panel = document.getElementById('scene-panel');
|
|
if (panel.classList.contains('collapsed'))
|
|
panel.querySelector('.panel-collapse-btn')?.click();
|
|
});
|
|
await desk.click('#vcam-toggle-btn');
|
|
if (process.env.QA_VCAM_DEBUG)
|
|
await desk.evaluate(() => {
|
|
window.__vcamSeen = [];
|
|
window.__godsEyeView.vcam.subscribe((s) =>
|
|
window.__vcamSeen.push([s.status, s.phoneConnected, s.driving, s.held, s.holdReason].join(',')),
|
|
);
|
|
});
|
|
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().code, { timeout: 15000 });
|
|
const pairing = await desk.evaluate(() => {
|
|
const state = window.__godsEyeView.vcam.getState();
|
|
const qr = document.getElementById('vcam-qr');
|
|
return {
|
|
state,
|
|
shownCode: document.getElementById('vcam-code').textContent,
|
|
qrDrawn: !qr.hidden && qr.width > 100,
|
|
link: document.getElementById('vcam-url').textContent,
|
|
};
|
|
});
|
|
check('VCAM opens a pairing session with a code and QR', pairing.qrDrawn && pairing.shownCode === pairing.state.code, pairing.link);
|
|
const phoneUrl = `https://127.0.0.1:${pairing.state.port}/#${pairing.state.code}`;
|
|
|
|
// 2. The real phone page, with WebSockets refused as on iOS.
|
|
const phone = await phoneBrowser.newPage();
|
|
phone.on('pageerror', (error) => errors.push(`phone: ${error.message}`));
|
|
if (process.env.QA_VCAM_DEBUG)
|
|
phone.on('request', (request) => {
|
|
if (request.url().includes('/api/') && !request.url().includes('/api/frame'))
|
|
console.log('PHONE', request.method(), request.url().split('?')[0], request.postData()?.slice(0, 90) ?? '');
|
|
});
|
|
await phone.emulate({
|
|
viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 },
|
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
|
|
});
|
|
await phone.evaluateOnNewDocument(() => {
|
|
window.WebSocket = class {
|
|
static OPEN = 1;
|
|
readyState = 0;
|
|
constructor() {
|
|
setTimeout(() => {
|
|
this.onerror?.(new Event('error'));
|
|
this.onclose?.({ code: 1006 });
|
|
}, 50);
|
|
}
|
|
close() {}
|
|
};
|
|
});
|
|
await phone.goto(phoneUrl, { waitUntil: 'domcontentloaded' });
|
|
await phone.waitForSelector('#start:not([hidden])', { timeout: 10000 });
|
|
check('the phone pairs from the QR link and clears the code from the address bar',
|
|
!(await phone.evaluate(() => location.hash)));
|
|
await phone.click('#start-gyro');
|
|
await phone.waitForSelector('#camera:not([hidden])', { timeout: 5000 });
|
|
const orient = (alpha, beta = 90, gamma = 0) =>
|
|
phone.evaluate((a, b, g) => {
|
|
window.dispatchEvent(new DeviceOrientationEvent('deviceorientation', { alpha: a, beta: b, gamma: g }));
|
|
}, alpha, beta, gamma);
|
|
await orient(0);
|
|
await desk
|
|
.waitForFunction(() => window.__godsEyeView.vcam.getState().driving, { timeout: 10000 })
|
|
.catch(async (error) => {
|
|
const { status, phoneConnected, held, holdReason, mode } = await desk.evaluate(() =>
|
|
window.__godsEyeView.vcam.getState(),
|
|
);
|
|
if (process.env.QA_VCAM_DEBUG)
|
|
console.log('SEEN', await desk.evaluate(() => JSON.stringify([...new Set(window.__vcamSeen)])));
|
|
throw new Error(`rig never drove: ${JSON.stringify({ status, phoneConnected, held, holdReason, mode })}`, {
|
|
cause: error,
|
|
});
|
|
});
|
|
const linkState = await phone.evaluate(() => document.getElementById('link-dot').className);
|
|
check('the phone falls back to HTTPS requests when WebSockets fail', linkState.includes('slow'), linkState);
|
|
const before = await desk.evaluate(() => window.__godsEyeView.styleManager.getCameraState());
|
|
for (let alpha = 0; alpha <= 45; alpha += 3) {
|
|
await orient(alpha);
|
|
await sleep(40);
|
|
}
|
|
// Headless WebGL renders a few frames a second; wait for the rig to settle.
|
|
const headingAfter = (target) =>
|
|
desk
|
|
.waitForFunction(
|
|
(from, wanted) => {
|
|
const { heading } = window.__godsEyeView.styleManager.getCameraState();
|
|
return Math.abs((((from - heading + 540) % 360) - 180) - wanted) < 1;
|
|
},
|
|
{ timeout: 15000, polling: 100 },
|
|
before.heading,
|
|
target,
|
|
)
|
|
.catch(() => {});
|
|
await headingAfter(45);
|
|
const turned = await desk.evaluate(() => window.__godsEyeView.styleManager.getCameraState());
|
|
const headingDelta = ((before.heading - turned.heading + 540) % 360) - 180;
|
|
check('turning the phone 45° left turns the camera 45° left', Math.abs(headingDelta - 45) < 1,
|
|
`heading ${before.heading.toFixed(1)} → ${turned.heading.toFixed(1)}`);
|
|
check('an upright phone looks at the horizon', Math.abs(turned.pitch) < 5, `pitch ${turned.pitch.toFixed(1)}`);
|
|
const vcamMode = await desk.evaluate(() => window.__godsEyeView.vcam.getState().mode);
|
|
check('the desktop reports gyro (3DoF) tracking', vcamMode === '3dof');
|
|
await phone.waitForFunction(() => document.getElementById('monitor').naturalWidth > 0, { timeout: 10000 });
|
|
check('the desktop render reaches the phone monitor', true);
|
|
|
|
// 3. Lens and a take from the phone.
|
|
await phone.click('#lens-btn');
|
|
await phone.click('#focal-presets button[data-focal="85"]');
|
|
await desk.waitForFunction(() => Math.abs(window.__godsEyeView.lens.getFocalLength() - 85) < 0.01, { timeout: 5000 });
|
|
const fov = await desk.evaluate(() => window.__godsEyeView.viewer.camera.frustum.fov);
|
|
check('the phone lens preset sets the desktop focal length', Math.abs(fov - 2 * Math.atan(18 / 85)) < 1e-6);
|
|
await phone.click('#lens-btn');
|
|
const shotsBefore = await desk.evaluate(() => {
|
|
const d = window.__godsEyeView.sceneDirector;
|
|
return d._getSelectedScene().shots.length;
|
|
});
|
|
await phone.click('#record-btn');
|
|
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().recording, { timeout: 5000 });
|
|
for (let alpha = 45; alpha >= -20; alpha -= 2) {
|
|
await orient(alpha, 80);
|
|
await sleep(50);
|
|
}
|
|
await phone.click('#record-btn');
|
|
await desk.waitForFunction(() => !window.__godsEyeView.vcam.getState().recording, { timeout: 5000 });
|
|
const take = await desk.evaluate(() => {
|
|
const d = window.__godsEyeView.sceneDirector;
|
|
const scene = d._getSelectedScene();
|
|
const shot = scene.shots.at(-1);
|
|
return {
|
|
count: scene.shots.length,
|
|
title: shot.title,
|
|
samples: shot.move?.track?.samples?.length || 0,
|
|
duration: shot.durationSec,
|
|
focal: shot.move?.track?.samples?.[0]?.[7],
|
|
listed: document.getElementById('scene-shot-list').textContent.includes(shot.title),
|
|
saved: JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((s) => s.shots.some((x) => x.id === shot.id)),
|
|
sceneId: scene.id,
|
|
shotId: shot.id,
|
|
last: shot.move?.track?.samples?.at(-1),
|
|
};
|
|
});
|
|
// One sample per rendered frame, at most 30 a second.
|
|
check('REC on the phone adds a track shot to the selected scene',
|
|
take.count === shotsBefore + 1 && take.samples >= 2 && take.duration > 2 &&
|
|
take.samples <= take.duration * 30 + 2 && take.listed && take.saved,
|
|
`${take.title}: ${take.samples} samples over ${take.duration}s`);
|
|
check('the take records the focal length per sample', take.focal === 85);
|
|
await desk.screenshot({ path: path.join(output, 'desktop-take.png') });
|
|
await phone.screenshot({ path: path.join(output, 'phone-camera.png') });
|
|
|
|
// Replaying the take ends exactly on its last sample.
|
|
await desk.evaluate(() => window.__godsEyeView.vcam.hold());
|
|
await desk.evaluate(({ sceneId, shotId }) =>
|
|
window.__godsEyeView.sceneDirector.loadShot(sceneId, shotId), take);
|
|
await desk.waitForFunction(({ last }) => {
|
|
const c = window.__godsEyeView.styleManager.getCameraState();
|
|
return Math.abs(c.lat - last[2]) < 1e-5 && Math.abs(c.lon - last[1]) < 1e-5;
|
|
}, { timeout: 20000 }, take).catch(() => {});
|
|
const replayed = await desk.evaluate(() => window.__godsEyeView.styleManager.getCameraState());
|
|
check('loading the take plays it to its final recorded pose',
|
|
Math.abs(replayed.lat - take.last[2]) < 1e-5 && Math.abs(replayed.alt - take.last[3]) < 0.5,
|
|
`alt ${replayed.alt.toFixed(1)} vs ${take.last[3]}`);
|
|
await phone.close();
|
|
|
|
// 4. A scripted 6DoF phone: translation follows the motion scale.
|
|
const join = await new Promise((resolve) => {
|
|
const request = https.request(
|
|
`https://127.0.0.1:${pairing.state.port}/api/join`,
|
|
{ method: 'POST', rejectUnauthorized: false, headers: { 'Content-Type': 'application/json' } },
|
|
(response) => {
|
|
let body = '';
|
|
response.on('data', (chunk) => (body += chunk));
|
|
response.on('end', () => resolve({ ok: response.statusCode === 200, body }));
|
|
},
|
|
);
|
|
request.on('error', () => resolve({ ok: false }));
|
|
request.end(JSON.stringify({ code: pairing.state.code }));
|
|
});
|
|
let sixDof = null;
|
|
if (join.ok) {
|
|
const { phoneKey } = JSON.parse(join.body);
|
|
sixDof = new WebSocket(`wss://127.0.0.1:${pairing.state.port}/ws?key=${phoneKey}`, {
|
|
origin: `https://127.0.0.1:${pairing.state.port}`,
|
|
rejectUnauthorized: false,
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
sixDof.once('open', resolve);
|
|
sixDof.once('error', reject);
|
|
});
|
|
}
|
|
check('a second phone can pair over a WebSocket', !!sixDof);
|
|
if (sixDof) {
|
|
const pose = (z, extra = {}) =>
|
|
sixDof.send(JSON.stringify({ type: 'pose', t: Date.now(), q: [0, 0, 0, 1], p: [0, 1.5, z], j: [0, 0, 0, 0], ...extra }));
|
|
sixDof.send(JSON.stringify({ type: 'set', key: 'scale', value: 100 }));
|
|
sixDof.send(JSON.stringify({ type: 'set', key: 'smoothing', value: 0 }));
|
|
sixDof.send(JSON.stringify({ type: 'cmd', cmd: 'release' }));
|
|
for (let i = 0; i < 10; i++) {
|
|
pose(0);
|
|
await sleep(30);
|
|
}
|
|
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().driving && window.__godsEyeView.vcam.getState().mode === '6dof', { timeout: 5000 });
|
|
const position = () =>
|
|
desk.evaluate(() => {
|
|
const { x, y, z } = window.__godsEyeView.viewer.camera.positionWC;
|
|
return [x, y, z];
|
|
});
|
|
const start = await position();
|
|
for (let i = 1; i <= 20; i++) {
|
|
pose(-i / 20);
|
|
await sleep(30);
|
|
}
|
|
await desk
|
|
.waitForFunction(
|
|
(from) => {
|
|
const { x, y, z } = window.__godsEyeView.viewer.camera.positionWC;
|
|
return Math.abs(Math.hypot(x - from[0], y - from[1], z - from[2]) - 100) < 1;
|
|
},
|
|
{ timeout: 15000, polling: 100 },
|
|
start,
|
|
)
|
|
.catch(() => {});
|
|
const end = await position();
|
|
const moved = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]);
|
|
check('one metre of phone motion at 1:100 moves the camera 100 m', Math.abs(moved - 100) < 1, `${moved.toFixed(1)} m`);
|
|
|
|
// Depth of field at a miniature scale, focused by a tap at the centre.
|
|
sixDof.send(JSON.stringify({ type: 'set', key: 'scale', value: 1000 }));
|
|
sixDof.send(JSON.stringify({ type: 'set', key: 'dof', value: true }));
|
|
sixDof.send(JSON.stringify({ type: 'set', key: 'fStop', value: 4 }));
|
|
// Pitch down 30 degrees, let the rig settle, then focus on the ground ahead.
|
|
for (let i = 0; i < 20; i++) {
|
|
pose(-1, { q: [-0.2588, 0, 0, 0.9659] });
|
|
await sleep(50);
|
|
}
|
|
await desk
|
|
.waitForFunction(() => window.__godsEyeView.styleManager.getCameraState().pitch < -29, {
|
|
timeout: 15000,
|
|
polling: 100,
|
|
})
|
|
.catch(() => {});
|
|
sixDof.send(JSON.stringify({ type: 'focus', x: 0.5, y: 0.5 }));
|
|
await sleep(2000);
|
|
const dof = await desk.evaluate(() => {
|
|
const { lens, viewer } = window.__godsEyeView;
|
|
const stage = viewer.scene.postProcessStages;
|
|
let found = null;
|
|
for (let i = 0; i < stage.length; i++)
|
|
if (stage.get(i).name === 'godsEyeView_depthOfField') found = stage.get(i);
|
|
return {
|
|
state: lens.getDepthOfField(),
|
|
enabled: !!found?.enabled,
|
|
ready: !!found?.ready,
|
|
errors: window.__vcamRenderErrors,
|
|
};
|
|
});
|
|
check('tap-to-focus enables depth of field at the rig scale',
|
|
dof.enabled && dof.state.scale === 1000 && dof.state.focusM > 1000 && dof.state.focusM < 5000 && dof.state.fStop === 4,
|
|
`focus ${dof.state.focusM?.toFixed?.(0)} m at 1:${dof.state.scale}`);
|
|
check('the depth-of-field shader compiles and renders', dof.ready && dof.errors.length === 0, dof.errors.join(' | '));
|
|
await desk.screenshot({ path: path.join(output, 'desktop-dof.png') });
|
|
|
|
// Mouse navigation on the globe holds the rig instead of fighting it.
|
|
await desk.mouse.click(640, 400);
|
|
const held = await desk.evaluate(() => window.__godsEyeView.vcam.getState());
|
|
check('desktop mouse input holds the rig', held.held && !held.driving, held.holdReason);
|
|
sixDof.close();
|
|
}
|
|
await desk.click('#vcam-end-btn');
|
|
await desk.waitForFunction(() => window.__godsEyeView.vcam.getState().status === 'idle', { timeout: 5000 });
|
|
check('END closes the session', true);
|
|
} catch (error) {
|
|
console.error(error);
|
|
failed++;
|
|
} finally {
|
|
await browser.close();
|
|
await phoneBrowser.close();
|
|
}
|
|
const relevant = errors.filter((message) => !/Failed to fetch|NetworkError|tile|imagery/i.test(message));
|
|
check('no page errors', relevant.length === 0, relevant.slice(0, 3).join(' | '));
|
|
console.log(failed ? `\n${failed} check(s) failed` : '\nAll VCAM checks passed');
|
|
process.exit(failed ? 1 : 0);
|