Files
gods-eye-view/server/vcam/plugin.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

523 lines
17 KiB
JavaScript

/**
* Virtual camera: lets a phone on the same network act as the app's camera.
*
* Two surfaces, deliberately separate:
*
* - On the app's own server (loopback only, behind the same gate as /mcp):
* `POST /api/vcam/session` creates a pairing session, `DELETE` ends it,
* `GET /api/vcam/ca.crt` downloads the local CA, and the desktop page joins
* its session over the `/api/vcam/host` WebSocket.
* - A separate HTTPS listener on the LAN (VCAM_PORT, default 4174) that starts
* only when the desktop creates a session and stops after the last one
* ends. It serves the static phone page and the phone side of the relay
* and nothing else, so pairing a phone never exposes the app's provider
* proxies or API keys to the network.
*
* The phone transport prefers a WebSocket and falls back to plain HTTPS
* requests (POST for poses, server-sent events for state), which work under
* a tapped-through certificate warning where iOS refuses WebSockets.
*/
import fs from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocketServer } from 'ws';
import { isAllowedHost } from '../../build/allowedHosts.js';
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
import { isLocalMcpRequest } from '../mcp/plugin.js';
import { MAX_FRAME_BYTES, MAX_TEXT_BYTES, createVcamRelay } from './relay.js';
import { loadLocalCertificates } from './tls.js';
const PHONE_DIR = fileURLToPath(new URL('./phone/', import.meta.url));
const PHONE_FILES = Object.freeze({
'/': ['index.html', 'text/html; charset=utf-8'],
'/index.html': ['index.html', 'text/html; charset=utf-8'],
'/phone.js': ['phone.js', 'text/javascript; charset=utf-8'],
'/orientation.js': ['orientation.js', 'text/javascript; charset=utf-8'],
'/phone.css': ['phone.css', 'text/css; charset=utf-8'],
'/icon.svg': ['icon.svg', 'image/svg+xml'],
});
const DEFAULT_PORT = 4174;
const MAX_BATCH_BYTES = 64 * 1024;
const BODY_TIMEOUT_MS = 10000;
const SSE_KEEPALIVE_MS = 15000;
// The listener lingers after the last session in case the desktop reloads.
const IDLE_SHUTDOWN_MS = 120000;
const CA_CONTENT_TYPE = 'application/x-x509-ca-cert';
/** Headers for every phone-listener response; the page is static. */
export const PHONE_HEADERS = Object.freeze({
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' blob: data:",
"connect-src 'self' wss:",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
].join('; '),
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Cache-Control': 'no-store',
'Permissions-Policy': 'xr-spatial-tracking=(self), camera=(self)',
});
/**
* IPv4 addresses a phone on the same network could reach, private ranges
* first (home and office Wi-Fi), then others. Loopback and link-local are
* left out.
*/
export function lanAddresses(interfaces = os.networkInterfaces()) {
const rank = (address) =>
address.startsWith('192.168.')
? 0
: address.startsWith('10.')
? 1
: /^172\.(1[6-9]|2\d|3[01])\./.test(address)
? 2
: 3;
const found = [];
for (const entries of Object.values(interfaces || {}))
for (const entry of entries || []) {
const family = entry.family === 4 ? 'IPv4' : entry.family;
if (family !== 'IPv4' || entry.internal) continue;
if (entry.address.startsWith('169.254.')) continue;
if (!found.includes(entry.address)) found.push(entry.address);
}
return found.sort((a, b) => rank(a) - rank(b));
}
const json = (res, status, body) => {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(body));
};
function readBody(req, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const timer = setTimeout(() => {
reject(Object.assign(new Error('timeout'), { status: 408 }));
req.destroy();
}, BODY_TIMEOUT_MS);
req.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
clearTimeout(timer);
reject(Object.assign(new Error('too large'), { status: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
/** Whether a desktop request may control pairing (loopback, same-origin). */
function isDesktopRequest(req, env, { requireOrigin = true } = {}) {
if (requireOrigin && !req.headers.origin) return false;
return isLocalMcpRequest({
remoteAddress: req.socket?.remoteAddress,
localPort: req.socket?.localPort,
host: req.headers.host || '',
origin: req.headers.origin,
headers: req.headers,
env,
});
}
/**
* Create the virtual-camera service: the relay, the lazily started phone
* listener and the handlers both servers mount.
*/
export function createVcamService({
env = process.env,
cacheDir = path.join(process.cwd(), '.gev-cache', 'vcam'),
port = Number.parseInt(env.VCAM_PORT, 10) || DEFAULT_PORT,
host = env.VCAM_HOST || '0.0.0.0',
addresses = () => lanAddresses(),
allowedHosts = [],
logger = console,
} = {}) {
let listener = null;
let listenerNames = '';
let starting = null;
let idleTimer = null;
let certificates = null;
const relay = createVcamRelay({
onSessionClosed: () => {
if (relay.size === 0) scheduleIdleShutdown();
},
});
const sockets = new WebSocketServer({
noServer: true,
maxPayload: MAX_FRAME_BYTES,
});
function scheduleIdleShutdown() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (relay.size === 0) stopListener();
}, IDLE_SHUTDOWN_MS);
idleTimer.unref?.();
}
function stopListener() {
const current = listener;
listener = null;
listenerNames = '';
current?.closeAllConnections?.();
current?.close();
}
/** Start (or restart for a changed address) the HTTPS phone listener. */
async function ensureListener(requested) {
clearTimeout(idleTimer);
const names = [...new Set(requested)];
const key = names.join(',');
if (listener && listenerNames === key) return;
if (starting) await starting.catch(() => {});
if (listener && listenerNames === key) return;
stopListener();
certificates = loadLocalCertificates(cacheDir, names);
const server = https.createServer(
{ cert: certificates.cert, key: certificates.key },
(req, res) => handlePhoneRequest(req, res),
);
server.on('upgrade', (req, socket, head) =>
handlePhoneUpgrade(req, socket, head),
);
server.on('clientError', (_error, socket) => socket.destroy());
starting = new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => {
server.off('error', reject);
resolve();
});
});
try {
await starting;
} finally {
starting = null;
}
listener = server;
listenerNames = key;
logger.info?.(
`[vcam] phone listener on https://${names[0]}:${port}/ (pairing session only)`,
);
}
const phoneHostAllowed = (req) =>
isAllowedHost(req.headers.host, allowedHosts);
function servePhoneFile(req, res, pathname) {
const entry = PHONE_FILES[pathname];
if (!entry) return false;
const [file, type] = entry;
let body;
try {
body = fs.readFileSync(path.join(PHONE_DIR, file));
} catch {
json(res, 500, { error: 'phone page unavailable' });
return true;
}
res.statusCode = 200;
res.setHeader('Content-Type', type);
res.end(req.method === 'HEAD' ? undefined : body);
return true;
}
function sendCa(res) {
res.statusCode = 200;
res.setHeader('Content-Type', CA_CONTENT_TYPE);
res.setHeader(
'Content-Disposition',
'attachment; filename="gods-eye-view-vcam-ca.crt"',
);
res.end(certificates?.ca || '');
}
function phoneSession(req, url) {
const key = req.headers['x-vcam-key'] || url.searchParams.get('key');
return relay.sessionForPhone(key);
}
async function handlePhoneRequest(req, res) {
for (const [name, value] of Object.entries(PHONE_HEADERS))
res.setHeader(name, value);
if (!phoneHostAllowed(req)) return json(res, 421, { error: 'bad host' });
let url;
try {
url = new URL(req.url, 'https://phone.invalid');
} catch {
return json(res, 400, { error: 'bad request' });
}
const { pathname } = url;
try {
if (req.method === 'GET' || req.method === 'HEAD') {
if (servePhoneFile(req, res, pathname)) return;
if (pathname === '/ca.crt') return sendCa(res);
}
if (pathname === '/api/join' && req.method === 'POST') {
let code = '';
try {
code = JSON.parse(await readBody(req, 1024))?.code;
} catch (error) {
if (error?.status) throw error;
}
const joined = relay.joinPhone(code);
if (!joined.ok) return json(res, 403, { error: joined.reason });
return json(res, 200, {
phoneKey: joined.phoneKey,
sessionId: joined.sessionId,
limits: { text: MAX_TEXT_BYTES },
});
}
if (pathname === '/api/send' && req.method === 'POST') {
const session = phoneSession(req, url);
if (!session) return json(res, 403, { error: 'unpaired' });
let messages;
try {
messages = JSON.parse(await readBody(req, MAX_BATCH_BYTES));
} catch (error) {
if (error?.status) throw error;
return json(res, 400, { error: 'expected a JSON array' });
}
if (!Array.isArray(messages) || messages.length > 64)
return json(res, 400, { error: 'expected a JSON array' });
let accepted = 0;
for (const message of messages)
if (relay.relay(session, 'phone', JSON.stringify(message)))
accepted += 1;
return json(res, 200, { accepted });
}
if (pathname === '/api/events' && req.method === 'GET')
return openEventStream(req, res, phoneSession(req, url));
if (pathname === '/api/frame' && req.method === 'GET') {
const session = phoneSession(req, url);
if (!session) return json(res, 403, { error: 'unpaired' });
const after = Number.parseInt(url.searchParams.get('after'), 10) || 0;
const frame = await relay.nextFrame(session, after, 2000);
if (!frame) {
res.statusCode = 204;
return res.end();
}
res.statusCode = 200;
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('X-Frame-Seq', String(frame.seq));
return res.end(frame.bytes);
}
json(res, 404, { error: 'not found' });
} catch (error) {
if (!res.headersSent)
json(res, error?.status || 500, { error: 'request failed' });
else res.end();
}
}
function openEventStream(req, res, session) {
if (!session) return json(res, 403, { error: 'unpaired' });
res.statusCode = 200;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
const keepAlive = setInterval(
() => res.write(': keep-alive\n\n'),
SSE_KEEPALIVE_MS,
);
const peer = {
send: (text) => res.write(`data: ${text}\n\n`),
close: () => res.end(),
};
const attached = relay.attachPhone(
req.headers['x-vcam-key'] ||
new URL(req.url, 'https://phone.invalid').searchParams.get('key'),
peer,
);
if (!attached) {
clearInterval(keepAlive);
return res.end();
}
req.on('close', () => {
clearInterval(keepAlive);
relay.detach(attached, 'phone', peer);
});
}
function refuseUpgrade(socket, status) {
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
}
function handlePhoneUpgrade(req, socket, head) {
let url;
try {
url = new URL(req.url, 'https://phone.invalid');
} catch {
return refuseUpgrade(socket, '400 Bad Request');
}
if (url.pathname !== '/ws' || !phoneHostAllowed(req))
return refuseUpgrade(socket, '404 Not Found');
// A WebSocket is not subject to CORS: only this page's own origin may open one.
if (req.headers.origin !== `https://${req.headers.host}`)
return refuseUpgrade(socket, '403 Forbidden');
const key = url.searchParams.get('key');
if (!relay.sessionForPhone(key))
return refuseUpgrade(socket, '403 Forbidden');
sockets.handleUpgrade(req, socket, head, (ws) => {
const peer = {
send: (text) => ws.readyState === ws.OPEN && ws.send(text),
close: (code, reason) => ws.close(code, reason),
};
const session = relay.attachPhone(key, peer);
if (!session) return ws.close(4003, 'unpaired');
ws.on('message', (data, isBinary) => {
if (isBinary) return;
relay.relay(session, 'phone', data.toString('utf8'));
});
ws.on('close', () => relay.detach(session, 'phone', peer));
ws.on('error', () => ws.terminate());
});
}
/** Mount the desktop routes on the app's own (dev or preview) server. */
function install(server) {
server.middlewares.use('/api/vcam', async (req, res, next) => {
const pathname = (req.url || '/').split('?')[0];
try {
if (pathname === '/ca.crt' && req.method === 'GET') {
if (!isDesktopRequest(req, env, { requireOrigin: false }))
return json(res, 403, { error: 'local only' });
if (!certificates)
certificates = loadLocalCertificates(cacheDir, [
...addresses(),
'localhost',
'127.0.0.1',
]);
return sendCa(res);
}
if (pathname !== '/session') return next();
if (!isDesktopRequest(req, env))
return json(res, 403, { error: 'local only' });
if (isSharingEnabled(env))
return json(res, 403, { error: 'sharing is enabled' });
if (req.method === 'POST') {
const lan = addresses();
if (!lan.length)
return json(res, 503, {
error: 'no network address',
code: 'no-network',
});
try {
await ensureListener([...lan, 'localhost', '127.0.0.1']);
} catch (error) {
return json(res, 503, {
error: 'phone listener unavailable',
code: error?.code === 'EADDRINUSE' ? 'port-in-use' : 'listen',
port,
});
}
const session = relay.createSession();
return json(res, 200, {
sessionId: session.id,
hostKey: session.hostKey,
code: session.code,
port: listener.address()?.port ?? port,
addresses: lan,
caFingerprint: certificates.caFingerprint,
});
}
if (req.method === 'DELETE') {
relay.endSession(req.headers['x-vcam-host-key']);
return json(res, 200, { ended: true });
}
return json(res, 405, { error: 'method not allowed' });
} catch {
if (!res.headersSent) json(res, 500, { error: 'request failed' });
}
});
server.httpServer?.on('upgrade', (req, socket, head) => {
let url;
try {
url = new URL(req.url, 'http://local.invalid');
} catch {
return;
}
if (url.pathname !== '/api/vcam/host') return;
// Browsers always send Origin on a WebSocket; it must be this app.
if (!isDesktopRequest(req, env))
return refuseUpgrade(socket, '403 Forbidden');
const key = url.searchParams.get('key');
if (!relay.sessionForHost(key))
return refuseUpgrade(socket, '403 Forbidden');
sockets.handleUpgrade(req, socket, head, (ws) => {
const peer = {
send: (text) => ws.readyState === ws.OPEN && ws.send(text),
close: (code, reason) => ws.close(code, reason),
};
const session = relay.attachHost(key, peer);
if (!session) return ws.close(4003, 'unknown session');
ws.on('message', (data, isBinary) => {
if (isBinary) relay.publishFrame(session, Buffer.from(data));
else relay.relay(session, 'host', data.toString('utf8'));
});
ws.on('close', () => relay.detach(session, 'host', peer));
ws.on('error', () => ws.terminate());
});
});
server.httpServer?.once('close', () => destroy());
}
function destroy() {
clearTimeout(idleTimer);
relay.closeAll();
stopListener();
sockets.close();
}
return {
install,
destroy,
relay,
ensureListener,
get listening() {
return !!listener;
},
get port() {
return listener?.address()?.port ?? port;
},
};
}
/** Vite plugin mounting the virtual-camera desktop routes and phone listener. */
export function vcamPlugin(options = {}) {
let service = null;
const install = (server) => {
service ??= createVcamService({
allowedHosts: server.config?.server?.allowedHosts || [],
logger: server.config?.logger || console,
...options,
});
service.install(server);
};
return {
name: 'gev-vcam',
configureServer: install,
configurePreviewServer: install,
};
}