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
This commit is contained in:
Claude committed 2026-10-19 04:03:02 +00:00
1 parent c330eb801a
commit 56586a234b
33 files changed
+5472 -6

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0a0a0f"/><circle cx="32" cy="32" r="17" fill="none" stroke="#00d4ff" stroke-width="3"/><circle cx="32" cy="32" r="7" fill="#00d4ff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8" stroke="#00d4ff" stroke-width="3" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 343 B

+154
View File
@@ -0,0 +1,154 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#0a0a0f" />
<title>GEV Virtual Camera</title>
<link rel="icon" href="icon.svg" />
<link rel="stylesheet" href="phone.css" />
</head>
<body>
<main id="app">
<section id="pair" class="screen">
<img class="logo" src="icon.svg" alt="" />
<h1>VIRTUAL CAMERA</h1>
<p class="lede">
Enter the code shown in God's Eye View under SCENES › VCAM.
</p>
<form id="pair-form" autocomplete="off">
<input
id="code"
inputmode="text"
autocapitalize="characters"
autocorrect="off"
spellcheck="false"
maxlength="6"
placeholder="CODE"
aria-label="Pairing code"
/>
<button id="join" type="submit" class="primary">PAIR</button>
</form>
<p id="pair-status" class="status" role="status"></p>
<details class="help">
<summary>Connection drops on iPhone?</summary>
<p>
Safari can load this page past a certificate warning but may still
refuse its live connection, so the camera falls back to slower
requests. To fix that once:
</p>
<ol>
<li>
<a id="ca-link" href="/ca.crt">Download the local certificate</a>
and allow the profile.
</li>
<li>Settings › General › VPN &amp; Device Management: install it.</li>
<li>
Settings › General › About › Certificate Trust Settings: turn on
full trust for "God's Eye View Local VCam CA".
</li>
</ol>
<p>The certificate only lets your phone trust this computer.</p>
</details>
</section>
<section id="start" class="screen" hidden>
<img class="logo" src="icon.svg" alt="" />
<h1>PAIRED</h1>
<p id="support" class="lede"></p>
<button id="start-xr" class="primary" hidden>START AR CAMERA</button>
<button id="start-gyro" class="primary">START GYRO CAMERA</button>
<p class="hint">
AR tracks position and rotation (6DoF). Gyro tracks rotation only;
move with the on-screen sticks.
</p>
<p id="start-status" class="status" role="status"></p>
</section>
<section id="camera" class="screen camera" hidden>
<img id="monitor" alt="" draggable="false" />
<div id="focus-ring" hidden></div>
<div class="topbar">
<span id="link-dot" class="dot" title="Connection"></span>
<span id="mode-readout" class="chip">—</span>
<span id="rec-readout" class="chip rec" hidden>● 00:00</span>
<span class="spacer"></span>
<span id="scale-readout" class="chip">1:1</span>
<span id="focal-readout" class="chip">31mm</span>
</div>
<p id="camera-status" class="toast" role="status" hidden></p>
<div class="stick left" id="stick-left" aria-label="Move">
<div class="knob"></div>
<span>MOVE</span>
</div>
<div class="stick right" id="stick-right" aria-label="Crane and pan">
<div class="knob"></div>
<span>CRANE · PAN</span>
</div>
<div class="bottombar">
<button id="hold-btn" class="tool" aria-pressed="false">HOLD</button>
<button id="recenter-btn" class="tool">RECENTER</button>
<button id="record-btn" class="record" aria-pressed="false">
<span class="record-dot"></span><span class="sr">RECORD</span>
</button>
<button id="scale-btn" class="tool" aria-expanded="false">
SCALE
</button>
<button id="lens-btn" class="tool" aria-expanded="false">LENS</button>
</div>
<div id="scale-drawer" class="drawer" hidden>
<div class="presets" id="scale-presets">
<button data-scale="1">1:1<small>WALK</small></button>
<button data-scale="10">1:10<small>CRANE</small></button>
<button data-scale="100">1:100<small>DRONE</small></button>
<button data-scale="1000">1:1K<small>FLYOVER</small></button>
<button data-scale="10000">1:10K<small>ORBIT</small></button>
</div>
<label
>MOTION SCALE <output id="scale-value">1:1</output>
<input id="scale-range" type="range" min="0" max="5" step="0.01" value="0"
/></label>
<label
>SMOOTHING <output id="smooth-value">30%</output>
<input id="smooth-range" type="range" min="0" max="100" step="1" value="30"
/></label>
<div class="toggles">
<label><input id="height-lock" type="checkbox" /> HEIGHT LOCK</label>
<label><input id="horizon-lock" type="checkbox" /> HORIZON LOCK</label>
</div>
</div>
<div id="lens-drawer" class="drawer" hidden>
<label
>FOCAL LENGTH <output id="focal-value">31mm</output>
<input id="focal-range" type="range" min="0" max="1" step="0.001" value="0.3"
/></label>
<div class="presets" id="focal-presets">
<button data-focal="14">14</button>
<button data-focal="24">24</button>
<button data-focal="35">35</button>
<button data-focal="50">50</button>
<button data-focal="85">85</button>
<button data-focal="200">200</button>
</div>
<div class="toggles">
<label><input id="dof-toggle" type="checkbox" /> DEPTH OF FIELD</label>
</div>
<label
>APERTURE <output id="fstop-value">f/2.8</output>
<input id="fstop-range" type="range" min="0" max="1" step="0.001" value="0.35"
/></label>
<p class="hint">Tap the picture to focus there.</p>
</div>
</section>
</main>
<script type="module" src="phone.js"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
/**
* Device orientation to the virtual camera's Y-up pose frame (+x right,
* +y up, -z forward), shared by the phone page and its tests.
*/
export const quat = {
multiply(a, b) {
const [ax, ay, az, aw] = a;
const [bx, by, bz, bw] = b;
return [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
},
axisAngle(axis, angle) {
const s = Math.sin(angle / 2);
return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(angle / 2)];
},
};
const DEG = Math.PI / 180;
// Device orientation is device→earth (x east, y north, z up). Rotating -90°
// about x maps that earth frame to the Y-up frame (north becomes -z).
const EARTH_TO_Y_UP = quat.axisAngle([1, 0, 0], -Math.PI / 2);
/** W3C DeviceOrientation (Z-X'-Y'') to a Y-up quaternion for the current screen orientation. */
export function orientationQuaternion(alpha, beta, gamma, screenAngle) {
const x = (beta || 0) * DEG;
const y = (gamma || 0) * DEG;
const z = (alpha || 0) * DEG;
const cX = Math.cos(x / 2);
const cY = Math.cos(y / 2);
const cZ = Math.cos(z / 2);
const sX = Math.sin(x / 2);
const sY = Math.sin(y / 2);
const sZ = Math.sin(z / 2);
const device = [
sX * cY * cZ - cX * sY * sZ,
cX * sY * cZ + sX * cY * sZ,
cX * cY * sZ + sX * sY * cZ,
cX * cY * cZ - sX * sY * sZ,
];
const screen = quat.axisAngle([0, 0, 1], -(screenAngle || 0) * DEG);
return quat.multiply(EARTH_TO_Y_UP, quat.multiply(device, screen));
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orientationQuaternion } from './orientation.js';
import { rotateByQuaternion } from '../../../src/vcam/rig.js';
const close = (actual, expected, message) =>
actual.forEach((value, i) =>
assert.ok(Math.abs(value - expected[i]) < 1e-9, `${message}: ${actual} vs ${expected}`),
);
const axes = (q) => ({
forward: rotateByQuaternion(q, [0, 0, -1]),
up: rotateByQuaternion(q, [0, 1, 0]),
});
test('an upright portrait phone looks out of its back along the Y-up forward axis', () => {
// beta 90: stood up from flat, screen toward the user, back toward north.
const { forward, up } = axes(orientationQuaternion(0, 90, 0, 0));
close(forward, [0, 0, -1], 'faces north (-z)');
close(up, [0, 1, 0], 'top edge up');
});
test('alpha turns the phone counterclockwise seen from above', () => {
const { forward } = axes(orientationQuaternion(90, 90, 0, 0));
close(forward, [-1, 0, 0], 'faces west');
});
test('a landscape screen reports the picture\'s up, not the device\'s top edge', () => {
// Rotated a quarter turn counterclockwise: the top edge points west and the
// right edge up, the back still faces north, and the screen angle is 90.
const { forward, up } = axes(orientationQuaternion(90, 0, -90, 90));
close(forward, [0, 0, -1], 'still faces north');
close(up, [0, 1, 0], 'picture up is world up');
});
test('a phone lying flat, screen up, looks straight down', () => {
const { forward, up } = axes(orientationQuaternion(0, 0, 0, 0));
close(forward, [0, -1, 0], 'down');
close(up, [0, 0, -1], 'top edge toward north');
});
+371
View File
@@ -0,0 +1,371 @@
:root {
--bg: #0a0a0f;
--glass: rgba(12, 12, 20, 0.72);
--border: rgba(255, 255, 255, 0.12);
--accent: #00d4ff;
--accent-dim: rgba(0, 212, 255, 0.18);
--text: #e8eaed;
--muted: rgba(232, 234, 237, 0.6);
--rec: #ff3b4e;
--mono: 'SF Mono', ui-monospace, 'JetBrains Mono', Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
overscroll-behavior: none;
}
[hidden] {
display: none !important;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
.screen {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: max(24px, env(safe-area-inset-top)) 24px
max(24px, env(safe-area-inset-bottom));
text-align: center;
}
.logo {
width: 64px;
height: 64px;
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 20px;
letter-spacing: 0.18em;
}
.lede,
.hint,
.status {
margin: 0;
max-width: 34ch;
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.status:empty {
display: none;
}
.status.error {
color: #ff8a95;
}
#pair-form {
display: flex;
gap: 8px;
}
#code {
width: 9.5ch;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--glass);
color: var(--text);
font: 600 22px var(--mono);
letter-spacing: 0.2em;
text-align: center;
text-transform: uppercase;
}
.primary {
min-width: 220px;
padding: 14px 20px;
border: 1px solid var(--accent);
border-radius: 10px;
background: var(--accent-dim);
font: 600 15px var(--mono);
letter-spacing: 0.12em;
}
#pair-form .primary {
min-width: 0;
}
.help {
max-width: 36ch;
color: var(--muted);
font-size: 13px;
text-align: left;
}
.help summary {
cursor: pointer;
text-align: center;
}
.help a {
color: var(--accent);
}
/* Camera monitor: the desktop's render fills the screen. */
.camera {
position: fixed;
inset: 0;
padding: 0;
overflow: hidden;
touch-action: none;
user-select: none;
-webkit-user-select: none;
background: #000;
}
#monitor {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.topbar,
.bottombar {
position: absolute;
left: max(10px, env(safe-area-inset-left));
right: max(10px, env(safe-area-inset-right));
display: flex;
align-items: center;
gap: 8px;
}
.topbar {
top: max(10px, env(safe-area-inset-top));
}
.bottombar {
bottom: max(12px, env(safe-area-inset-bottom));
justify-content: center;
gap: 10px;
}
.spacer {
flex: 1;
}
.chip {
padding: 5px 9px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--glass);
font: 600 12px var(--mono);
letter-spacing: 0.06em;
}
.chip.rec {
border-color: var(--rec);
color: var(--rec);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #777;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.4);
}
.dot.live {
background: #3ddc84;
}
.dot.slow {
background: #ffb020;
}
.toast {
position: absolute;
top: calc(max(10px, env(safe-area-inset-top)) + 40px);
left: 50%;
transform: translateX(-50%);
margin: 0;
padding: 7px 12px;
border-radius: 10px;
background: var(--glass);
font-size: 13px;
white-space: nowrap;
}
.tool {
min-width: 64px;
padding: 10px 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--glass);
font: 600 11px var(--mono);
letter-spacing: 0.08em;
}
.tool[aria-pressed='true'],
.tool[aria-expanded='true'] {
border-color: var(--accent);
background: var(--accent-dim);
}
.record {
display: grid;
place-items: center;
width: 64px;
height: 64px;
border: 3px solid #fff;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
}
.record-dot {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--rec);
transition:
border-radius 150ms,
width 150ms,
height 150ms;
}
.record[aria-pressed='true'] .record-dot {
width: 24px;
height: 24px;
border-radius: 5px;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
/* Virtual sticks sit low on each side, under the thumbs. */
.stick {
position: absolute;
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 84px);
width: 128px;
height: 128px;
border: 1px solid var(--border);
border-radius: 50%;
background: rgba(12, 12, 20, 0.38);
touch-action: none;
}
.stick.left {
left: max(18px, env(safe-area-inset-left));
}
.stick.right {
right: max(18px, env(safe-area-inset-right));
}
.stick span {
position: absolute;
bottom: -20px;
left: 0;
right: 0;
color: var(--muted);
font: 600 10px var(--mono);
letter-spacing: 0.1em;
text-align: center;
}
.knob {
position: absolute;
top: 50%;
left: 50%;
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
border: 1px solid var(--accent);
border-radius: 50%;
background: var(--accent-dim);
}
.drawer {
position: absolute;
left: 50%;
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 84px);
transform: translateX(-50%);
width: min(360px, calc(100% - 24px));
max-height: 60%;
overflow: auto;
padding: 14px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(12, 12, 20, 0.9);
display: flex;
flex-direction: column;
gap: 12px;
font: 600 11px var(--mono);
letter-spacing: 0.08em;
z-index: 2;
}
.drawer label {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
}
.drawer input[type='range'] {
width: 100%;
accent-color: var(--accent);
}
.drawer output {
color: var(--accent);
}
.presets {
display: flex;
gap: 6px;
}
.presets button {
flex: 1;
padding: 8px 2px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
font: 600 12px var(--mono);
}
.presets button.active {
border-color: var(--accent);
background: var(--accent-dim);
}
.presets small {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 8px;
}
.toggles {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.toggles label {
justify-content: flex-start;
align-items: center;
}
.drawer .hint {
font: 12px var(--sans);
letter-spacing: 0;
}
#focus-ring {
position: absolute;
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border: 2px solid #ffd60a;
border-radius: 6px;
pointer-events: none;
animation: focus 700ms ease-out forwards;
}
@keyframes focus {
from {
transform: scale(1.5);
opacity: 1;
}
to {
transform: scale(1);
opacity: 0;
}
}
@media (orientation: landscape) and (max-height: 500px) {
.stick {
width: 112px;
height: 112px;
bottom: calc(max(12px, env(safe-area-inset-bottom)) + 24px);
}
.bottombar {
left: 160px;
right: 160px;
}
}
+770
View File
@@ -0,0 +1,770 @@
/**
* God's Eye View virtual camera — phone side.
*
* Streams this phone's pose to the desktop app and shows the app's render as
* a monitor. Tracking uses WebXR `immersive-ar` (6DoF, Android Chrome with
* ARCore) when available, otherwise device orientation (3DoF, iPhone Safari)
* with on-screen sticks for movement. Poses are sent in one Y-up frame for
* both: +x right, +y up, -z forward (WebXR's convention); yaw is arbitrary
* and the desktop aligns it on recenter.
*
* Transport: a WebSocket when the connection allows it, otherwise HTTPS
* requests (iOS refuses WebSockets under a tapped-through certificate). The
* monitor always pulls the newest frame, so a slow network drops frames
* instead of building a backlog.
*/
import { orientationQuaternion } from './orientation.js';
const $ = (id) => document.getElementById(id);
const KEY_STORAGE = 'gevVcam.phoneKey';
const CODE_STORAGE = 'gevVcam.code';
const SCALE_PRESETS = [1, 10, 100, 1000, 10000];
const FOCAL_RANGE = [12, 400];
const FSTOP_RANGE = [1.2, 22];
const STICK_DEADZONE = 0.12;
const store = {
get(key) {
try {
return sessionStorage.getItem(key);
} catch {
return null;
}
},
set(key, value) {
try {
if (value === null) sessionStorage.removeItem(key);
else sessionStorage.setItem(key, value);
} catch {
// Private browsing: pairing still works for this page load.
}
},
};
const logMap = ([low, high], v) => low * (high / low) ** v;
const logUnmap = ([low, high], value) =>
Math.log(value / low) / Math.log(high / low);
const round = (value, places = 5) => {
const k = 10 ** places;
return Math.round(value * k) / k;
};
const formatScale = (scale) =>
scale >= 1000
? `1:${+(scale / 1000).toFixed(scale >= 10000 ? 0 : 1)}K`
: `1:${+scale.toFixed(scale < 10 ? 1 : 0)}`;
const formatTime = (sec) => {
const s = Math.max(0, Math.floor(sec));
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};
const screenAngle = () =>
screen.orientation?.angle ?? (Number(window.orientation) || 0);
// ------------------------------------------------------------------ transport
class Link {
constructor(key, { onMessage, onStatus, onUnpaired }) {
this.key = key;
this.onMessage = onMessage;
this.onStatus = onStatus;
this.onUnpaired = onUnpaired;
this.ws = null;
this.events = null;
this.mode = 'connecting';
this.queue = [];
this.pendingPose = null;
this.inFlight = false;
this.closed = false;
this.webSocketFailed = false;
this.retryTimer = null;
}
async start() {
if (this.closed) return;
this.onStatus('connecting');
if (!this.webSocketFailed && (await this.openWebSocket())) return;
this.webSocketFailed = true;
this.openHttp();
}
openWebSocket() {
return new Promise((resolve) => {
let settled = false;
let ws;
try {
ws = new WebSocket(
`wss://${location.host}/ws?key=${encodeURIComponent(this.key)}`,
);
} catch {
resolve(false);
return;
}
const done = (ok) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(ok);
};
const timer = setTimeout(() => {
ws.close();
done(false);
}, 3500);
ws.onopen = () => {
this.ws = ws;
this.mode = 'ws';
this.onStatus('live');
done(true);
};
ws.onerror = () => done(false);
ws.onmessage = (event) => this.receive(event.data);
ws.onclose = (event) => {
done(false);
if (this.ws !== ws) return;
this.ws = null;
this.mode = 'connecting';
this.handleClose(event.code);
};
});
}
openHttp() {
if (this.closed) return;
this.mode = 'http';
const events = new EventSource(
`/api/events?key=${encodeURIComponent(this.key)}`,
);
this.events = events;
events.onopen = () => this.onStatus('slow');
events.onmessage = (event) => this.receive(event.data);
events.onerror = async () => {
if (events.readyState !== EventSource.CLOSED || this.closed) return;
this.events = null;
// A closed stream is either a lost network or an ended session.
try {
const probe = await fetch('/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Vcam-Key': this.key,
},
body: '[]',
cache: 'no-store',
});
if (probe.status === 403) return this.handleClose(4003);
} catch {
// Offline: retry below.
}
this.handleClose(1006);
};
this.flush();
}
handleClose(code) {
if (this.closed) return;
if (code === 4003 || code === 4000) {
this.onUnpaired(code === 4000 ? 'ended' : 'unpaired');
return;
}
if (code === 4001) {
this.onStatus('replaced');
this.close();
return;
}
this.onStatus('connecting');
clearTimeout(this.retryTimer);
this.retryTimer = setTimeout(() => this.start(), 1000);
}
receive(text) {
try {
this.onMessage(JSON.parse(text));
} catch {
// Ignore malformed relay input.
}
}
/** Poses are latest-wins; every other message is delivered in order. */
send(message) {
if (this.closed) return;
// 1 is WebSocket.OPEN.
if (this.ws && this.ws.readyState === 1) {
this.ws.send(JSON.stringify(message));
return;
}
if (message.type === 'pose') this.pendingPose = message;
else this.queue.push(message);
if (this.mode === 'http') this.flush();
}
async flush() {
if (this.inFlight || this.mode !== 'http' || this.closed) return;
const batch = this.queue.splice(0, 32);
if (this.pendingPose) batch.push(this.pendingPose);
this.pendingPose = null;
if (!batch.length) return;
this.inFlight = true;
try {
const response = await fetch('/api/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Vcam-Key': this.key,
},
body: JSON.stringify(batch),
cache: 'no-store',
});
if (response.status === 403) this.handleClose(4003);
} catch {
// The event stream's error handling owns reconnection.
} finally {
this.inFlight = false;
}
if (this.queue.length || this.pendingPose) this.flush();
}
close() {
this.closed = true;
clearTimeout(this.retryTimer);
this.ws?.close();
this.events?.close();
this.ws = null;
this.events = null;
}
}
// --------------------------------------------------------------------- state
const app = {
key: store.get(KEY_STORAGE),
link: null,
tracking: null, // { mode: '6dof'|'3dof', stop() }
orientation: null,
sticks: { left: [0, 0], right: [0, 0] },
host: {
connected: false,
recording: false,
elapsedSec: 0,
scale: 1,
focalMm: 31.18,
fStop: 2.8,
dof: false,
held: false,
smoothing: 0.3,
heightLock: false,
horizonLock: false,
},
recordingSince: 0,
monitorRunning: false,
wakeLock: null,
};
function show(screenId) {
for (const id of ['pair', 'start', 'camera']) $(id).hidden = id !== screenId;
}
function setStatus(element, text, error = false) {
element.textContent = text || '';
element.classList.toggle('error', !!error);
}
let toastTimer = null;
function toast(text) {
const element = $('camera-status');
element.textContent = text;
element.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (element.hidden = true), 2600);
}
// ------------------------------------------------------------------- pairing
async function join(code) {
setStatus($('pair-status'), 'Pairing…');
try {
const response = await fetch('/api/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
cache: 'no-store',
});
if (response.status === 403) {
setStatus(
$('pair-status'),
'That code is not active. Check the code on the desktop.',
true,
);
return false;
}
if (!response.ok) throw new Error(String(response.status));
const { phoneKey } = await response.json();
app.key = phoneKey;
store.set(KEY_STORAGE, phoneKey);
store.set(CODE_STORAGE, code);
setStatus($('pair-status'), '');
connect();
showStart();
return true;
} catch {
setStatus(
$('pair-status'),
'Could not reach the desktop. Is it on the same network?',
true,
);
return false;
}
}
function connect() {
app.link?.close();
app.link = new Link(app.key, {
onMessage: handleMessage,
onStatus: presentLink,
onUnpaired: async (reason) => {
app.link?.close();
app.link = null;
store.set(KEY_STORAGE, null);
stopTracking();
const code = store.get(CODE_STORAGE);
if (reason === 'unpaired' && code && (await join(code))) return;
show('pair');
setStatus(
$('pair-status'),
reason === 'ended'
? 'The desktop ended this session. Enter a new code.'
: 'Pairing expired. Enter the code shown on the desktop.',
true,
);
},
});
app.link.start();
}
function presentLink(status) {
const dot = $('link-dot');
dot.classList.toggle('live', status === 'live');
dot.classList.toggle('slow', status === 'slow');
dot.title =
status === 'live'
? 'Live connection'
: status === 'slow'
? 'Fallback connection (install the certificate for lower latency)'
: 'Connecting…';
if (status === 'replaced') toast('Another phone took over this session');
}
async function showStart() {
show('start');
const xr = await xrSupported();
$('start-xr').hidden = !xr;
$('support').textContent = xr
? 'This phone supports AR tracking. Point it like a camera.'
: 'AR tracking is not available in this browser, so the gyro camera tracks rotation and the sticks move you.';
}
async function xrSupported() {
try {
return !!(await navigator.xr?.isSessionSupported('immersive-ar'));
} catch {
return false;
}
}
// ------------------------------------------------------------------ tracking
function sendPose(q, p) {
const { left, right } = app.sticks;
app.link?.send({
type: 'pose',
t: Math.round(performance.now()),
mode: app.tracking?.mode,
q: q.map((v) => round(v)),
p: p ? p.map((v) => round(v, 4)) : null,
j: [left[0], left[1], right[0], right[1]].map((v) => round(v, 3)),
});
}
async function startXr() {
setStatus($('start-status'), 'Starting AR…');
try {
const root = $('camera');
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['local'],
optionalFeatures: ['dom-overlay'],
domOverlay: { root },
});
// Screen taps on controls must not become XR select input.
root.addEventListener('beforexrselect', (event) => event.preventDefault());
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', { xrCompatible: true, alpha: true });
await gl.makeXRCompatible?.();
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
const space = await session.requestReferenceSpace('local');
let stopped = false;
const onFrame = (_time, frame) => {
if (stopped) return;
session.requestAnimationFrame(onFrame);
const pose = frame.getViewerPose(space);
if (pose) {
const { position, orientation } = pose.transform;
sendPose(
[orientation.x, orientation.y, orientation.z, orientation.w],
pose.emulatedPosition ? null : [position.x, position.y, position.z],
);
}
const layer = session.renderState.baseLayer;
gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
};
session.requestAnimationFrame(onFrame);
session.addEventListener('end', () => {
stopped = true;
if (app.tracking?.session === session) {
app.tracking = null;
showStart();
}
});
app.tracking = {
mode: '6dof',
session,
stop: () => session.end().catch(() => {}),
};
enterCamera();
} catch (error) {
setStatus(
$('start-status'),
`AR could not start (${error?.message || error}). Try the gyro camera.`,
true,
);
}
}
async function startGyro() {
// iOS asks for motion access only from a tap, before anything else awaits.
try {
if (typeof DeviceOrientationEvent?.requestPermission === 'function') {
const answer = await DeviceOrientationEvent.requestPermission();
if (answer !== 'granted') {
setStatus(
$('start-status'),
'Motion access was denied. Allow it in Safari settings for this site, then reload.',
true,
);
return;
}
}
} catch {
setStatus($('start-status'), 'Motion access is unavailable.', true);
return;
}
let stopped = false;
const onOrientation = (event) => {
if (event.alpha === null && event.beta === null) return;
app.orientation = orientationQuaternion(
event.alpha,
event.beta,
event.gamma,
screenAngle(),
);
};
window.addEventListener('deviceorientation', onOrientation);
const tick = () => {
if (stopped) return;
requestAnimationFrame(tick);
if (app.orientation) sendPose(app.orientation, null);
};
requestAnimationFrame(tick);
app.tracking = {
mode: '3dof',
stop: () => {
stopped = true;
window.removeEventListener('deviceorientation', onOrientation);
},
};
document.documentElement.requestFullscreen?.().catch(() => {});
enterCamera();
setTimeout(() => {
if (!app.orientation && !stopped)
toast('No motion data yet — this browser may block the gyroscope');
}, 1500);
}
function stopTracking() {
app.tracking?.stop();
app.tracking = null;
app.monitorRunning = false;
}
async function enterCamera() {
show('camera');
$('mode-readout').textContent =
app.tracking?.mode === '6dof' ? 'AR 6DOF' : 'GYRO 3DOF';
app.link?.send({
type: 'hello',
mode: app.tracking?.mode,
screen: [window.innerWidth, window.innerHeight],
});
try {
app.wakeLock = await navigator.wakeLock?.request('screen');
} catch {
// Optional: the screen may dim during long takes.
}
runMonitor();
}
// ------------------------------------------------------------------- monitor
async function runMonitor() {
if (app.monitorRunning) return;
app.monitorRunning = true;
const monitor = $('monitor');
let seq = 0;
let previousUrl = null;
while (app.monitorRunning && app.key) {
if (document.hidden) {
await new Promise((resolve) => setTimeout(resolve, 500));
continue;
}
try {
const response = await fetch(
`/api/frame?key=${encodeURIComponent(app.key)}&after=${seq}`,
{ cache: 'no-store' },
);
if (response.status === 403) break;
if (response.status !== 200) continue;
seq = Number(response.headers.get('X-Frame-Seq')) || seq + 1;
const url = URL.createObjectURL(await response.blob());
await new Promise((resolve) => {
monitor.onload = resolve;
monitor.onerror = resolve;
monitor.src = url;
});
if (previousUrl) URL.revokeObjectURL(previousUrl);
previousUrl = url;
} catch {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
app.monitorRunning = false;
}
// ------------------------------------------------------------------ controls
function stick(element, target) {
const knob = element.querySelector('.knob');
let pointer = null;
const set = (x, y) => {
target[0] = x;
target[1] = y;
const radius = element.clientWidth / 2 - 26;
knob.style.transform = `translate(${x * radius}px, ${-y * radius}px)`;
};
const move = (event) => {
const rect = element.getBoundingClientRect();
const radius = rect.width / 2;
let x = (event.clientX - rect.left - radius) / radius;
let y = -(event.clientY - rect.top - radius) / radius;
const length = Math.hypot(x, y);
if (length > 1) {
x /= length;
y /= length;
}
// Rescale past the dead zone so small nudges stay precise.
const live = Math.min(1, Math.hypot(x, y));
const scale =
live < STICK_DEADZONE
? 0
: (live - STICK_DEADZONE) / (1 - STICK_DEADZONE) / live;
set(x * scale, y * scale);
};
element.addEventListener('pointerdown', (event) => {
if (pointer !== null) return;
pointer = event.pointerId;
element.setPointerCapture(pointer);
move(event);
});
element.addEventListener('pointermove', (event) => {
if (event.pointerId === pointer) move(event);
});
const release = (event) => {
if (event.pointerId !== pointer) return;
pointer = null;
set(0, 0);
};
element.addEventListener('pointerup', release);
element.addEventListener('pointercancel', release);
}
const sendSet = (key, value) => app.link?.send({ type: 'set', key, value });
const sendCommand = (command) => app.link?.send({ type: 'cmd', cmd: command });
function presentHost() {
const h = app.host;
$('record-btn').setAttribute('aria-pressed', String(h.recording));
$('rec-readout').hidden = !h.recording;
$('hold-btn').setAttribute('aria-pressed', String(h.held));
$('scale-readout').textContent = formatScale(h.scale);
$('scale-value').textContent = formatScale(h.scale);
$('focal-readout').textContent = `${Math.round(h.focalMm)}mm`;
$('focal-value').textContent = `${Math.round(h.focalMm)}mm`;
$('fstop-value').textContent = `f/${h.fStop.toFixed(1)}`;
$('smooth-value').textContent = `${Math.round(h.smoothing * 100)}%`;
const active = (element) =>
element === document.activeElement && element.matches(':active');
if (!active($('scale-range')))
$('scale-range').value = String(Math.log10(h.scale));
if (!active($('focal-range')))
$('focal-range').value = String(logUnmap(FOCAL_RANGE, h.focalMm));
if (!active($('fstop-range')))
$('fstop-range').value = String(logUnmap(FSTOP_RANGE, h.fStop));
if (!active($('smooth-range')))
$('smooth-range').value = String(Math.round(h.smoothing * 100));
$('dof-toggle').checked = h.dof;
$('height-lock').checked = h.heightLock;
$('horizon-lock').checked = h.horizonLock;
for (const button of $('scale-presets').querySelectorAll('button'))
button.classList.toggle(
'active',
Math.abs(Number(button.dataset.scale) - h.scale) < 1e-6,
);
for (const button of $('focal-presets').querySelectorAll('button'))
button.classList.toggle(
'active',
Math.abs(Number(button.dataset.focal) - h.focalMm) < 0.5,
);
}
function handleMessage(message) {
if (message.type === 'state') {
const wasRecording = app.host.recording;
Object.assign(app.host, message.state);
if (app.host.recording && !wasRecording)
app.recordingSince = performance.now() - app.host.elapsedSec * 1000;
presentHost();
} else if (message.type === 'peer' && message.role === 'host') {
app.host.connected = message.connected;
if (!message.connected) toast('Desktop disconnected — waiting…');
} else if (message.type === 'toast' && typeof message.text === 'string') {
toast(message.text.slice(0, 140));
}
}
function toggleDrawer(name) {
for (const other of ['scale', 'lens']) {
const open = other === name && $(`${other}-drawer`).hidden;
$(`${other}-drawer`).hidden = !open;
$(`${other}-btn`).setAttribute('aria-expanded', String(open));
}
}
function bindControls() {
$('pair-form').addEventListener('submit', (event) => {
event.preventDefault();
const code = $('code').value.trim().toUpperCase();
if (code) join(code);
});
$('start-xr').addEventListener('click', startXr);
$('start-gyro').addEventListener('click', startGyro);
stick($('stick-left'), app.sticks.left);
stick($('stick-right'), app.sticks.right);
$('record-btn').addEventListener('click', () =>
sendCommand(app.host.recording ? 'stop' : 'record'),
);
$('hold-btn').addEventListener('click', () =>
sendCommand(app.host.held ? 'release' : 'hold'),
);
$('recenter-btn').addEventListener('click', () => sendCommand('recenter'));
$('scale-btn').addEventListener('click', () => toggleDrawer('scale'));
$('lens-btn').addEventListener('click', () => toggleDrawer('lens'));
$('scale-presets').addEventListener('click', (event) => {
const value = Number(event.target.closest('button')?.dataset.scale);
if (SCALE_PRESETS.includes(value)) sendSet('scale', value);
});
$('scale-range').addEventListener('input', (event) => {
const scale = 10 ** Number(event.target.value);
$('scale-value').textContent = formatScale(scale);
sendSet('scale', round(scale, 3));
});
$('smooth-range').addEventListener('input', (event) =>
sendSet('smoothing', Number(event.target.value) / 100),
);
$('height-lock').addEventListener('change', (event) =>
sendSet('heightLock', event.target.checked),
);
$('horizon-lock').addEventListener('change', (event) =>
sendSet('horizonLock', event.target.checked),
);
$('focal-range').addEventListener('input', (event) => {
const focal = logMap(FOCAL_RANGE, Number(event.target.value));
$('focal-value').textContent = `${Math.round(focal)}mm`;
sendSet('focalMm', round(focal, 2));
});
$('focal-presets').addEventListener('click', (event) => {
const value = Number(event.target.closest('button')?.dataset.focal);
if (value) sendSet('focalMm', value);
});
$('dof-toggle').addEventListener('change', (event) =>
sendSet('dof', event.target.checked),
);
$('fstop-range').addEventListener('input', (event) => {
const fStop = logMap(FSTOP_RANGE, Number(event.target.value));
$('fstop-value').textContent = `f/${fStop.toFixed(1)}`;
sendSet('fStop', round(fStop, 2));
});
// Tap the monitor to focus: coordinates are within the shown picture.
$('monitor').addEventListener('click', (event) => {
const monitor = $('monitor');
if (!monitor.naturalWidth) return;
const rect = monitor.getBoundingClientRect();
const fit = Math.min(
rect.width / monitor.naturalWidth,
rect.height / monitor.naturalHeight,
);
const width = monitor.naturalWidth * fit;
const height = monitor.naturalHeight * fit;
const x = (event.clientX - rect.left - (rect.width - width) / 2) / width;
const y = (event.clientY - rect.top - (rect.height - height) / 2) / height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
app.link?.send({ type: 'focus', x: round(x, 4), y: round(y, 4) });
const ring = $('focus-ring');
ring.hidden = true;
ring.style.left = `${event.clientX}px`;
ring.style.top = `${event.clientY}px`;
void ring.offsetWidth;
ring.hidden = false;
});
setInterval(() => {
if (app.host.recording)
$('rec-readout').textContent =
`● ${formatTime((performance.now() - app.recordingSince) / 1000)}`;
}, 250);
// The system releases a wake lock whenever the page is hidden.
document.addEventListener('visibilitychange', async () => {
if (document.hidden || !app.tracking) return;
if (app.wakeLock && !app.wakeLock.released) return;
try {
app.wakeLock = await navigator.wakeLock?.request('screen');
} catch {
// Optional.
}
});
}
function boot() {
bindControls();
presentHost();
const fromLink = decodeURIComponent(location.hash.slice(1))
.toUpperCase()
.replace(/[^0-9A-Z]/g, '');
if (fromLink) {
$('code').value = fromLink;
store.set(CODE_STORAGE, fromLink);
// Keep the code out of the address bar once read.
history.replaceState(null, '', location.pathname);
}
if (app.key) {
connect();
showStart();
} else if (fromLink) {
join(fromLink);
} else {
show('pair');
}
}
boot();