Files
gods-eye-view/src/vcam/controller.js
T
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

707 lines
21 KiB
JavaScript

/**
* Desktop side of the virtual camera: pairs a phone through the local relay,
* drives the Cesium camera from its pose, streams a preview back and records
* takes into the Scene Director.
*
* Camera ownership follows the application's rules. The rig claims the camera
* through the shared navigation seam (so Cockpit can refuse it) and lets go
* — holds — whenever anything else takes it: a search, a scene playing, or
* the mouse on the globe. Releasing the hold re-anchors the rig wherever the
* camera now is, so nothing jumps.
*/
import * as Cesium from 'cesium';
import { createRig, SCALE_RANGE } from './rig.js';
import { msg, t } from '../i18n/index.js';
import {
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
const RECORD_INTERVAL_MS = 1000 / 30;
// The scene document's per-array limit.
export const MAX_TAKE_SAMPLES = 10000;
const PREVIEW_FPS = 12;
const PREVIEW_WIDTH = 720;
const PREVIEW_QUALITY = 0.62;
const PREVIEW_BACKLOG_BYTES = 512 * 1024;
// Sticks stop when the phone stops talking (a dropped link, a locked screen).
const STALE_POSE_MS = 400;
const STATE_HEARTBEAT_MS = 1000;
const MIN_GROUND_CLEARANCE_M = 0.5;
const MAX_STEP_SEC = 0.5;
const MAX_RECONNECT_ATTEMPTS = 5;
const RENDER_HOLD = 'vcam';
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const vectorOf = (value, length) =>
Array.isArray(value) && value.length === length && value.every(finite)
? value
: null;
const round = (value, places) => {
const k = 10 ** places;
return Math.round(value * k) / k;
};
/**
* @param {object} options
* @param {Cesium.Viewer} options.viewer
* @param {object} options.navigation - Shell navigation: `runImmediateNavigation`, `subscribeCameraHandoff`.
* @param {object} options.director - SceneDirector (`addRecordedShot`).
* @param {object} options.lens - Camera lens (`setFocalLength`, `setDepthOfField`, ...).
*/
export function createVcamController({
viewer,
navigation,
director,
lens,
fetchImpl = (...args) => fetch(...args),
WebSocketImpl = globalThis.WebSocket,
pageLocation = globalThis.location,
now = () => performance.now(),
}) {
const camera = viewer.camera;
const scene = viewer.scene;
const listeners = new Set();
const rig = createRig();
const state = {
status: 'idle',
error: null,
code: null,
port: null,
addresses: [],
address: null,
caFingerprint: null,
phoneConnected: false,
mode: null,
driving: false,
held: false,
holdReason: null,
recording: false,
recordStartedAt: 0,
takes: 0,
lastTake: null,
};
let session = null;
let socket = null;
let reconnectAttempts = 0;
let latestPose = null;
let lastStep = null;
let anchor = null;
let claiming = false;
let rendering = false;
let samples = [];
let lastSampleAt = -Infinity;
let previewBusy = false;
let lastPreviewAt = -Infinity;
let heartbeat = null;
let destroyed = false;
const previewCanvas = document.createElement('canvas');
const removers = [];
// ---------------------------------------------------------------- state
const snapshot = () => ({
...state,
pairingUrl:
state.address && state.port && state.code
? `https://${state.address}:${state.port}/#${state.code}`
: null,
recordingSec: state.recording ? (now() - state.recordStartedAt) / 1000 : 0,
settings: settings(),
});
function settings() {
const dof = lens?.getDepthOfField?.() || {};
return {
...rig.settings,
focalMm: round(lens?.getFocalLength?.() ?? 31.18, 2),
fStop: dof.fStop ?? 2.8,
dof: !!dof.enabled,
};
}
function publish() {
const current = snapshot();
for (const listener of listeners) {
try {
listener(current);
} catch (error) {
console.error('[vcam] listener failed', error);
}
}
sendPhoneState(current);
}
function sendPhoneState(current = snapshot()) {
send({
type: 'state',
state: {
recording: current.recording,
elapsedSec: round(current.recordingSec, 2),
held: current.held,
takes: current.takes,
...current.settings,
},
});
}
function send(message) {
if (socket?.readyState === 1) socket.send(JSON.stringify(message));
}
function toastPhone(text) {
send({ type: 'toast', text });
}
// ------------------------------------------------------------- session
/** Create a pairing session and connect the desktop end of the relay. */
async function start() {
if (destroyed || session) return snapshot();
Object.assign(state, { status: 'starting', error: null });
publish();
let response;
try {
response = await fetchImpl('/api/vcam/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
} catch {
return fail(msg('The phone camera service is unavailable'));
}
const body = await response.json().catch(() => ({}));
if (!response.ok)
return fail(
body.code === 'port-in-use'
? msg('The phone camera port is in use (set VCAM_PORT)')
: body.code === 'no-network'
? msg('No network connection for a phone to reach')
: msg('The phone camera service is unavailable'),
);
if (destroyed) return snapshot();
session = body;
reconnectAttempts = 0;
Object.assign(state, {
status: 'waiting',
code: body.code,
port: body.port,
addresses: body.addresses || [],
address: body.addresses?.[0] || null,
caFingerprint: body.caFingerprint || null,
});
openSocket();
heartbeat = setInterval(() => {
if (state.phoneConnected) sendPhoneState();
}, STATE_HEARTBEAT_MS);
publish();
return snapshot();
}
function fail(error) {
Object.assign(state, { status: 'error', error });
publish();
return snapshot();
}
function openSocket() {
if (!session || destroyed) return;
const protocol = pageLocation.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocketImpl(
`${protocol}//${pageLocation.host}/api/vcam/host?key=${encodeURIComponent(session.hostKey)}`,
);
ws.binaryType = 'arraybuffer';
socket = ws;
ws.onopen = () => {
if (socket === ws) reconnectAttempts = 0;
};
ws.onmessage = (event) => {
if (socket !== ws || typeof event.data !== 'string') return;
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
handleMessage(message);
};
ws.onclose = (event) => {
if (socket !== ws) return;
socket = null;
setPhoneConnected(false);
// 4000: the server ended the session; anything else may be transient.
// A restarted server refuses the old key, which looks like a network
// failure from here; give up rather than retry forever.
if (
event.code === 4000 ||
event.code === 4003 ||
++reconnectAttempts > MAX_RECONNECT_ATTEMPTS
) {
endLocally(msg('Phone camera session ended'));
return;
}
setTimeout(() => {
if (session && !socket) openSocket();
}, 1500);
};
}
/** End the pairing session and release the camera. */
async function stop() {
const ending = session;
endLocally(null);
if (ending)
await fetchImpl('/api/vcam/session', {
method: 'DELETE',
headers: { 'X-Vcam-Host-Key': ending.hostKey },
}).catch(() => {});
return snapshot();
}
function endLocally(error) {
if (state.recording) stopRecording();
session = null;
const ws = socket;
socket = null;
ws?.close();
clearInterval(heartbeat);
heartbeat = null;
setPhoneConnected(false);
Object.assign(state, {
status: error ? 'error' : 'idle',
error,
code: null,
port: null,
addresses: [],
address: null,
mode: null,
});
stopDriving();
publish();
}
function setPhoneConnected(connected) {
if (state.phoneConnected === connected) return;
state.phoneConnected = connected;
if (!connected) {
latestPose = null;
stopDriving();
if (state.recording) stopRecording();
}
if (connected && state.status === 'waiting') state.status = 'connected';
if (!connected && state.status === 'connected') state.status = 'waiting';
syncRenderHold();
publish();
}
// ------------------------------------------------------------ messages
function handleMessage(message) {
switch (message?.type) {
case 'code':
if (typeof message.code === 'string') {
state.code = message.code.slice(0, 16);
publish();
}
return;
case 'peer':
if (message.role === 'phone') setPhoneConnected(!!message.connected);
return;
case 'hello':
state.mode = message.mode === '6dof' ? '6dof' : '3dof';
setPhoneConnected(true);
publish();
return;
case 'pose':
return receivePose(message);
case 'cmd':
return command(message.cmd);
case 'set':
return set(message.key, message.value);
case 'focus':
if (finite(message.x) && finite(message.y))
focusAt(message.x, message.y);
return;
}
}
function receivePose(message) {
const q = vectorOf(message.q, 4);
if (!q) return;
latestPose = {
q,
p: message.p === null ? null : vectorOf(message.p, 3),
joy: (vectorOf(message.j, 4) || [0, 0, 0, 0]).map((v) =>
Math.max(-1, Math.min(1, v)),
),
at: now(),
};
const mode = latestPose.p ? '6dof' : '3dof';
if (state.mode !== mode) {
state.mode = mode;
publish();
}
if (!state.phoneConnected) setPhoneConnected(true);
if (!state.driving && !state.held) beginDriving();
}
function command(name) {
if (name === 'record') startRecording();
else if (name === 'stop') stopRecording();
else if (name === 'hold') hold('phone');
else if (name === 'release') release();
else if (name === 'recenter') recenter();
}
/** Change one setting from either the phone or the desktop panel. */
function set(key, value) {
if (key === 'scale' && finite(value)) {
const scale = Math.max(SCALE_RANGE[0], Math.min(SCALE_RANGE[1], value));
shiftAnchor(rig.configure({ scale }));
// Depth of field treats the world at the rig's scale: a miniature.
lens?.setDepthOfField?.({ scale });
} else if (key === 'smoothing' && finite(value))
rig.configure({ smoothing: value });
else if (
(key === 'heightLock' || key === 'horizonLock') &&
typeof value === 'boolean'
)
shiftAnchor(rig.configure({ [key]: value }));
else if (key === 'focalMm' && finite(value)) lens?.setFocalLength?.(value);
else if (key === 'fStop' && finite(value))
lens?.setDepthOfField?.({ fStop: value });
else if (key === 'dof' && typeof value === 'boolean')
lens?.setDepthOfField?.({ enabled: value, scale: rig.settings.scale });
else return;
publish();
}
// -------------------------------------------------------------- camera
function claimCamera() {
if (typeof navigation?.runImmediateNavigation !== 'function') return true;
claiming = true;
try {
return navigation.runImmediateNavigation('vcam', () => true) !== false;
} finally {
claiming = false;
}
}
function beginDriving() {
if (!latestPose || state.driving) return;
if (!claimCamera()) {
hold('refused');
toastPhone(t('Camera unavailable — exit cockpit first'));
return;
}
camera.cancelFlight?.();
reanchor();
state.driving = true;
state.held = false;
state.holdReason = null;
syncRenderHold();
publish();
}
function stopDriving() {
state.driving = false;
lastStep = null;
syncRenderHold();
}
/** Start the phone's motion from the camera's current pose. */
function reanchor() {
anchor = Cesium.Cartesian3.clone(camera.positionWC);
if (latestPose) {
rig.step({ ...latestPose, joy: [0, 0, 0, 0], dt: 0 });
rig.recenter(camera.heading);
}
lastStep = null;
}
function hold(reason = 'phone') {
if (state.held && state.holdReason === reason) return;
state.held = true;
state.holdReason = reason;
stopDriving();
publish();
}
function release() {
if (!state.held) return;
state.held = false;
state.holdReason = null;
if (latestPose) beginDriving();
publish();
}
function recenter() {
if (!state.driving) return release();
reanchor();
publish();
}
function shiftAnchor(shift) {
if (!anchor || !shift || (!shift[0] && !shift[1] && !shift[2])) return;
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(anchor);
const delta = Cesium.Matrix4.multiplyByPointAsVector(
frame,
new Cesium.Cartesian3(shift[0], shift[1], shift[2]),
new Cesium.Cartesian3(),
);
anchor = Cesium.Cartesian3.add(anchor, delta, anchor);
}
function placeCamera() {
if (!state.driving || state.held || !latestPose || !anchor) return;
const at = now();
// Smoothing and sticks follow wall time, so a slow frame rate does not
// slow the camera; only a long stall (a hidden tab) is cut short.
const dt =
lastStep === null ? 0 : Math.min(MAX_STEP_SEC, (at - lastStep) / 1000);
lastStep = at;
const stale = at - latestPose.at > STALE_POSE_MS;
const out = rig.step({
...latestPose,
joy: stale ? [0, 0, 0, 0] : latestPose.joy,
dt,
});
shiftAnchor(out.anchorShift);
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(anchor);
const destination = Cesium.Matrix4.multiplyByPoint(
frame,
Cesium.Cartesian3.fromArray(out.offset),
new Cesium.Cartesian3(),
);
const direction = Cesium.Cartesian3.normalize(
Cesium.Matrix4.multiplyByPointAsVector(
frame,
Cesium.Cartesian3.fromArray(out.forward),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const up = Cesium.Cartesian3.normalize(
Cesium.Matrix4.multiplyByPointAsVector(
frame,
Cesium.Cartesian3.fromArray(out.up),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
keepAboveGround(destination);
camera.setView({ destination, orientation: { direction, up } });
}
/** Stop at the terrain surface rather than passing under it. */
function keepAboveGround(position) {
const cartographic = Cesium.Cartographic.fromCartesian(position);
if (!cartographic) return;
const ground = scene.globe?.show
? scene.globe.getHeight(cartographic)
: undefined;
if (!finite(ground)) return;
const floor = ground + MIN_GROUND_CLEARANCE_M;
if (cartographic.height >= floor) return;
cartographic.height = floor;
Cesium.Cartographic.toCartesian(cartographic, undefined, position);
}
/** Tap-to-focus: focus the lens on what lies under a normalized point. */
function focusAt(x, y) {
const canvas = scene.canvas;
const point = new Cesium.Cartesian2(
Math.max(0, Math.min(1, x)) * canvas.clientWidth,
Math.max(0, Math.min(1, y)) * canvas.clientHeight,
);
let target;
try {
if (scene.pickPositionSupported) target = scene.pickPosition(point);
} catch {
target = undefined;
}
if (!target) {
const ray = camera.getPickRay(point);
target = ray && scene.globe?.pick(ray, scene);
}
if (!target) {
toastPhone(t('Nothing to focus on there'));
return;
}
const focusM = Cesium.Cartesian3.distance(camera.positionWC, target);
lens?.setDepthOfField?.({
enabled: true,
focusM,
scale: rig.settings.scale,
});
publish();
}
// ----------------------------------------------------------- recording
function startRecording() {
if (state.recording || !session) return;
samples = [];
lastSampleAt = -Infinity;
state.recording = true;
state.recordStartedAt = now();
syncRenderHold();
publish();
}
function recordSample() {
if (!state.recording) return;
const at = now();
if (at - lastSampleAt < RECORD_INTERVAL_MS - 1) return;
const carto = camera.positionCartographic;
if (!carto) return;
lastSampleAt = at;
const t0 = samples.length
? state.recordStartedAt
: (state.recordStartedAt = at);
samples.push([
round((at - t0) / 1000, 3),
round(Cesium.Math.toDegrees(carto.longitude), 7),
round(Cesium.Math.toDegrees(carto.latitude), 7),
round(carto.height, 2),
round(Cesium.Math.toDegrees(camera.heading), 2),
round(Cesium.Math.clamp(Cesium.Math.toDegrees(camera.pitch), -90, 90), 2),
round(Cesium.Math.toDegrees(camera.roll), 2),
round(lens?.getFocalLength?.() ?? 31.18, 2),
]);
if (samples.length >= MAX_TAKE_SAMPLES) {
stopRecording();
toastPhone(t('Take reached its length limit and was saved'));
}
}
/** Stop recording and add the take to the selected scene. */
function stopRecording() {
if (!state.recording) return null;
state.recording = false;
syncRenderHold();
const take = samples;
samples = [];
let result = null;
if (take.length >= 2) {
// Playback takes the short arc between samples, so headings may wrap;
// rounding can still reach 360, which stays inside the ±360 bound.
for (const row of take)
for (const column of [4, 6])
row[column] = round(((row[column] % 360) + 360) % 360, 2);
const dof = lens?.getDepthOfField?.();
result = director?.addRecordedShot?.({
samples: take,
lens: {
focalMm: take.at(-1)[7],
...(dof?.enabled ? { dof: { ...dof } } : {}),
},
});
if (result) {
state.takes += 1;
state.lastTake = { ...result, durationSec: take.at(-1)[0] };
toastPhone(t('Take saved to the scene'));
}
} else toastPhone(t('Take too short to keep'));
publish();
return result;
}
// ------------------------------------------------------------- preview
function sendPreview() {
if (!state.phoneConnected || socket?.readyState !== 1 || previewBusy)
return;
const at = now();
if (at - lastPreviewAt < 1000 / PREVIEW_FPS) return;
if (socket.bufferedAmount > PREVIEW_BACKLOG_BYTES) return;
const source = scene.canvas;
if (!source?.width || !source?.height) return;
lastPreviewAt = at;
previewBusy = true;
const width = Math.min(PREVIEW_WIDTH, source.width);
const height = Math.round((width * source.height) / source.width);
if (previewCanvas.width !== width) previewCanvas.width = width;
if (previewCanvas.height !== height) previewCanvas.height = height;
// Copy inside postRender, while the drawing buffer still holds this frame.
previewCanvas.getContext('2d').drawImage(source, 0, 0, width, height);
previewCanvas.toBlob(
async (blob) => {
try {
if (blob && socket?.readyState === 1)
socket.send(await blob.arrayBuffer());
} finally {
previewBusy = false;
}
},
'image/jpeg',
PREVIEW_QUALITY,
);
}
function syncRenderHold() {
const wanted =
!destroyed && (state.driving || state.phoneConnected || state.recording);
if (wanted === rendering) return;
rendering = wanted;
if (wanted) holdContinuousRender(RENDER_HOLD);
else releaseContinuousRender(RENDER_HOLD);
}
// ------------------------------------------------------------ wiring
removers.push(scene.preUpdate.addEventListener(() => placeCamera()));
removers.push(
scene.postRender.addEventListener(() => {
recordSample();
sendPreview();
}),
);
const yieldToNavigation = () => {
if (!claiming && state.driving) hold('navigation');
};
const unsubscribeHandoff =
navigation?.subscribeCameraHandoff?.(yieldToNavigation);
if (unsubscribeHandoff) removers.push(unsubscribeHandoff);
const canvas = scene.canvas;
const yieldToMouse = () => {
if (state.driving) hold('desktop');
};
for (const type of ['pointerdown', 'wheel']) {
canvas?.addEventListener(type, yieldToMouse, { passive: true });
removers.push(() => canvas?.removeEventListener(type, yieldToMouse));
}
return {
start,
stop,
hold: () => hold('desktop-button'),
release,
recenter,
set,
startRecording,
stopRecording,
toggleRecording: () =>
state.recording ? stopRecording() : startRecording(),
setAddress(address) {
if (state.addresses.includes(address)) {
state.address = address;
publish();
}
},
getState: snapshot,
subscribe(listener) {
listeners.add(listener);
listener(snapshot());
return () => listeners.delete(listener);
},
get active() {
return !!session;
},
destroy() {
if (destroyed) return;
const ending = session;
endLocally(null);
if (ending)
fetchImpl('/api/vcam/session', {
method: 'DELETE',
headers: { 'X-Vcam-Host-Key': ending.hostKey },
keepalive: true,
}).catch(() => {});
destroyed = true;
syncRenderHold();
for (const remove of removers.splice(0)) remove();
listeners.clear();
},
};
}