Files
gods-eye-view/server/vcam/relay.test.mjs
Claude 56586a234b feat(vcam): use a phone on the network as a virtual camera
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
2026-10-19 04:03:02 +00:00

167 lines
6.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_FRAME_BYTES,
MAX_TEXT_BYTES,
PAIRING_CODE_LENGTH,
createVcamRelay,
} from './relay.js';
function peer() {
const sent = [];
const closed = [];
return {
sent,
closed,
messages: () => sent.map((text) => JSON.parse(text)),
send: (text) => sent.push(text),
close: (code, reason) => closed.push([code, reason]),
};
}
function clock() {
let at = 0;
const timers = new Set();
return {
now: () => at,
advance(ms) {
at += ms;
for (const timer of [...timers])
if (timer.due <= at) {
timers.delete(timer);
timer.fn();
}
},
setTimer: (fn, ms) => {
const timer = { fn, due: at + ms };
timers.add(timer);
return timer;
},
clearTimer: (timer) => timers.delete(timer),
};
}
test('a phone pairs with the displayed code and relays both ways', () => {
const relay = createVcamRelay();
const session = relay.createSession();
assert.equal(session.code.length, PAIRING_CODE_LENGTH);
assert.match(session.code, /^[2-9A-HJKMNP-Z]+$/, 'no ambiguous characters');
const host = peer();
assert.equal(relay.attachHost(session.hostKey, host), session);
assert.deepEqual(host.messages(), [
{ type: 'code', code: session.code },
{ type: 'peer', role: 'phone', connected: false },
]);
const joined = relay.joinPhone(` ${session.code.toLowerCase().slice(0, 3)}-${session.code.slice(3)} `);
assert.equal(joined.ok, true, 'codes are case- and separator-insensitive');
const phone = peer();
assert.equal(relay.attachPhone(joined.phoneKey, phone), session);
assert.deepEqual(host.messages().at(-1), { type: 'peer', role: 'phone', connected: true });
assert.ok(relay.relay(session, 'phone', '{"type":"pose","q":[0,0,0,1]}'));
assert.equal(host.sent.at(-1), '{"type":"pose","q":[0,0,0,1]}');
assert.ok(relay.relay(session, 'host', '{"type":"state"}'));
assert.equal(phone.sent.at(-1), '{"type":"state"}');
relay.detach(session, 'phone', phone);
assert.deepEqual(host.messages().at(-1), { type: 'peer', role: 'phone', connected: false });
});
test('keys are per role and a new phone join replaces the previous phone', () => {
const relay = createVcamRelay();
const session = relay.createSession();
assert.equal(relay.attachPhone(session.hostKey, peer()), null);
assert.equal(relay.attachHost('nope', peer()), null);
const first = relay.joinPhone(session.code);
const phone = peer();
relay.attachPhone(first.phoneKey, phone);
const second = relay.joinPhone(session.code);
assert.notEqual(first.phoneKey, second.phoneKey);
assert.deepEqual(phone.closed, [[4001, 'replaced']]);
assert.equal(relay.sessionForPhone(first.phoneKey), null, 'the old key is revoked');
});
test('wrong codes rotate the pairing code and tell the desktop', () => {
const relay = createVcamRelay();
const session = relay.createSession();
const host = peer();
relay.attachHost(session.hostKey, host);
const original = session.code;
for (let i = 0; i < 7; i++) assert.equal(relay.joinPhone('ZZZZZZ').ok, false);
assert.equal(session.code, original);
relay.joinPhone('ZZZZZZ');
assert.notEqual(session.code, original);
assert.deepEqual(host.messages().at(-1), { type: 'code', code: session.code });
assert.equal(relay.joinPhone(original).ok, false, 'the old code no longer pairs');
assert.equal(relay.joinPhone(session.code).ok, true);
});
test('oversize, non-object and over-rate messages are refused', () => {
const time = clock();
const relay = createVcamRelay(time);
const session = relay.createSession();
const host = peer();
relay.attachHost(session.hostKey, host);
const big = JSON.stringify({ type: 'x', pad: 'a'.repeat(MAX_TEXT_BYTES) });
assert.equal(relay.relay(session, 'phone', big), false);
assert.equal(relay.relay(session, 'phone', 'not json'), false);
assert.equal(relay.relay(session, 'phone', '[1,2]'), false);
let admitted = 0;
for (let i = 0; i < 400; i++) if (relay.relay(session, 'phone', '{}')) admitted++;
// The two malformed messages above already spent their tokens.
assert.equal(admitted, 238, 'a burst is capped, malformed input included');
time.advance(500);
assert.equal(relay.relay(session, 'phone', '{}'), true, 'tokens refill over time');
});
test('the newest preview frame is pulled, not queued', async () => {
const relay = createVcamRelay();
const session = relay.createSession();
const jpeg = (n) => Buffer.from([0xff, 0xd8, n]);
assert.equal(relay.publishFrame(session, Buffer.from('GIF89a')), false, 'only JPEG');
assert.equal(relay.publishFrame(session, Buffer.alloc(MAX_FRAME_BYTES + 1, 0xff)), false);
assert.equal(await relay.nextFrame(session, 0, 0), null);
const waiting = relay.nextFrame(session, 0, 5000);
relay.publishFrame(session, jpeg(1));
relay.publishFrame(session, jpeg(2));
const first = await waiting;
assert.equal(first.seq, 1);
const latest = await relay.nextFrame(session, first.seq, 0);
assert.equal(latest.seq, 2, 'a slow phone skips straight to the newest frame');
assert.equal(latest.bytes[2], 2);
relay.closeSession(session);
assert.equal(relay.publishFrame(session, jpeg(3)), false);
});
test('a session outlives a desktop reload but not a departed desktop', () => {
const time = clock();
const closed = [];
const relay = createVcamRelay({ ...time, onSessionClosed: (s) => closed.push(s.id) });
const session = relay.createSession();
const host = peer();
relay.attachHost(session.hostKey, host);
const phone = peer();
relay.attachPhone(relay.joinPhone(session.code).phoneKey, phone);
relay.detach(session, 'host', host);
assert.deepEqual(phone.messages().at(-1), { type: 'peer', role: 'host', connected: false });
time.advance(30000);
relay.attachHost(session.hostKey, peer());
time.advance(60000);
assert.equal(relay.size, 1, 'a reattached host keeps the session');
relay.detach(session, 'host', relay.sessionForHost(session.hostKey).host);
time.advance(60001);
assert.equal(relay.size, 0);
assert.deepEqual(closed, [session.id]);
assert.deepEqual(phone.closed, [[4000, 'host-gone']]);
});
test('ending a session closes both peers and only the host key can end it', () => {
const relay = createVcamRelay();
const session = relay.createSession();
const phone = peer();
relay.attachPhone(relay.joinPhone(session.code).phoneKey, phone);
assert.equal(relay.endSession('wrong'), false);
assert.equal(relay.endSession(session.hostKey), true);
assert.deepEqual(phone.closed, [[4000, 'ended']]);
for (let i = 0; i < 6; i++) relay.createSession();
assert.equal(relay.size, 4, 'sessions are capped');
});