1
0

feat: add ephemeral webcam faces and adaptive screens

This commit is contained in:
2026-08-11 22:38:56 -07:00
parent 3326d2e6d0
commit d4859b33c6
17 changed files with 1107 additions and 11 deletions
+126 -1
View File
@@ -73,11 +73,18 @@ import {
actorKindForPresence,
createProfileEditor,
createDefaultLocalProfile,
createWebcamCapture,
createWebcamFaceConsent,
createWebcamFacePanel,
createWebcamFaceTexture,
loadLocalProfile,
resolveHumanoidAppearance,
saveLocalProfile,
type LocalProfile,
type ProfileEditor,
type WebcamCaptureController,
type WebcamFacePanel,
type WebcamFaceTextureAdapter,
} from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.ts";
import { SRGBColorSpace, VideoTexture } from "three";
@@ -964,6 +971,7 @@ async function mountCity(id: string) {
return;
}
city = handle;
attachCurrentWebcamFace();
moveRealtimePresence();
// A new board builds a new layer, and a new layer starts visible. Reapply
// whatever the panel last said, or the setting silently undoes itself on the
@@ -1282,6 +1290,7 @@ async function enterOffice() {
}
city.stage.setScene(office);
inside = true;
attachCurrentWebcamFace();
moveRealtimePresence();
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
journeyToCity(desiredCity);
@@ -1408,6 +1417,7 @@ function leaveOffice() {
dispatchJourney({ type: "leave-office" });
city.stage.setScene(city.stageScene);
inside = false;
attachCurrentWebcamFace();
moveRealtimePresence();
showPlan();
showDetail(null);
@@ -1587,8 +1597,13 @@ const screensButton = document.querySelector<HTMLButtonElement>("#screens");
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
let profileEditor: ProfileEditor | null = null;
let webcamFacePanel: WebcamFacePanel | null = null;
let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
let webcamFaceConsent = createWebcamFaceConsent();
let webcamCapture: WebcamCaptureController | null = null;
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -2116,6 +2131,7 @@ function clearRealtimePeers(): void {
function applyRealtimeMessage(message: ServerRealtimeMessage): void {
if (message.type === "membership-revoked") {
stopWebcamFace(true);
clearRealtimePeers();
return;
}
@@ -2236,6 +2252,7 @@ async function initializeRealtimePresence(): Promise<void> {
window.addEventListener("pagehide", () => {
realtimePageActive = false;
stopWebcamFace(true);
disposeOfficeScreenUi();
realtimeOperation += 1;
stopRealtimeSubscription?.();
@@ -2267,6 +2284,96 @@ function applyProfilePreview(profile: LocalProfile): void {
}
}
function cameraSupported(): boolean {
return typeof navigator.mediaDevices?.getUserMedia === "function";
}
/** Attach the one ephemeral texture to whichever signed-in humanoid is current. */
function attachCurrentWebcamFace(): void {
const texture = webcamFaceTexture?.texture() ?? null;
if (!texture || access.subject === null) return;
city?.clearActorFaceTexture();
office?.walker?.clearFaceTexture();
if (inside) office?.walker?.attachFaceTexture(texture);
else city?.attachActorFaceTexture(texture);
}
/**
* Stop app-owned capture. The texture adapter deliberately never owns tracks,
* so this boundary must stop every one before dropping the stream reference.
*/
function stopWebcamFace(revoke = false): void {
city?.clearActorFaceTexture();
office?.walker?.clearFaceTexture();
webcamFaceTexture?.clear();
webcamCapture?.stop();
if (revoke) webcamFaceConsent.revoke();
else webcamFaceConsent.stop();
webcamFaceTexture?.sync();
webcamFacePanel?.update(cameraSupported() ? "off" : "unsupported");
}
async function startWebcamFace(): Promise<void> {
if (access.subject === null || !localProfile || !cameraSupported() || webcamCapture?.status() === "active" ||
webcamCapture?.status() === "requesting") return;
webcamFacePanel?.update("requesting");
webcamFaceConsent.requestStart();
webcamCapture ??= createWebcamCapture({
acquire: () => navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 640 } },
}),
createVideo: () => {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.hidden = true;
document.body.append(video);
return video;
},
});
let binding: Awaited<ReturnType<WebcamCaptureController["start"]>>;
try {
binding = await webcamCapture.start();
} catch (error) {
webcamFaceConsent.stop();
const denied = error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError");
webcamFacePanel?.update(
"error",
denied ? "Camera permission was denied. Nothing was captured." : "Camera could not start. Check the device and try again.",
);
return;
}
// Null means Stop/pagehide overtook an asynchronous permission or play step.
if (!binding) return;
if (access.subject === null) {
stopWebcamFace(true);
return;
}
try {
const consent = webcamFaceConsent.start(true);
if (!consent.accepted) throw new Error("camera consent was no longer active");
webcamFaceTexture ??= createWebcamFaceTexture({ consent: webcamFaceConsent });
webcamFaceTexture.bind(binding);
for (const track of binding.stream.getVideoTracks()) {
track.addEventListener("ended", () => {
if (webcamCapture?.binding()?.stream === binding.stream) stopWebcamFace();
}, { once: true });
}
attachCurrentWebcamFace();
webcamFacePanel?.update("active");
// Native permission UI can restore focus to the page after the promise
// resolves. Put it back inside the still-open modal on the next frame.
requestAnimationFrame(() => {
if (profileEditor?.state().open) webcamFacePanel?.focusStop();
});
} catch {
stopWebcamFace();
webcamFacePanel?.update("error", "Camera preview could not start. Nothing was retained.");
}
}
function ensureProfileEditor(): ProfileEditor | null {
if (profileEditor) return profileEditor;
if (!profileOverlay || !localProfile || access.subject === null) return null;
@@ -2297,6 +2404,19 @@ function ensureProfileEditor(): ProfileEditor | null {
},
});
profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
if (webcamFaceIndicator) {
const host = document.createElement("div");
profileEditor.root.insertBefore(host, profileEditor.root.querySelector("form")?.nextSibling ?? null);
webcamFacePanel = createWebcamFacePanel({
container: host,
indicatorContainer: webcamFaceIndicator,
supported: cameraSupported(),
onStart: startWebcamFace,
onStop: () => stopWebcamFace(),
});
if (webcamCapture?.status() === "active") webcamFacePanel.update("active");
profileEditor.registerFocusables(webcamFacePanel.focusables);
}
return profileEditor;
}
@@ -2526,7 +2646,12 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
try {
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
video: {
displaySurface: "browser",
width: { ideal: 1_280, max: 1_280 },
height: { ideal: 720, max: 720 },
frameRate: { ideal: 15, max: 15 },
},
audio: false,
// Chromium honours these as chooser preferences. Other browsers ignore
// unknown dictionary members and still require the same explicit prompt.