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
523 lines
17 KiB
JavaScript
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,
|
|
};
|
|
}
|