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
+16 -2
View File
@@ -120,6 +120,12 @@ rejected; selected cell concurrency passes a repeatable load test.
## M5 — profile and webcam faces
Status: **implemented and integrated**. Signed-in members can start an optional
camera face only from the Character dialog; a persistent live indicator and
one-click Stop remain visible while active. Capture is tab-ephemeral, audio-free,
never recorded or uploaded, follows the humanoid across scene transitions, and
is torn down on Stop, track end, revocation, or page exit.
- Generated profile face is the default.
- Webcam face is opt-in and ephemeral: explicit contextual permission, active
indicator, one-click stop, no recording/storage by default.
@@ -131,9 +137,17 @@ cannot subscribe; the full product works without a camera.
## M6 — office media surfaces
Status: **implemented and integrated for peer-to-peer hosted sharing**. Authored
Three.js monitors remain dark until an authorized viewer explicitly opts in;
presenters choose a tab/window through the browser prompt and have an immediate
kill switch. Signaling grants, TURN grants, video textures, late join, reconnect,
and revoke lifecycle are bounded and tested. Presenter encoding defaults to
1280×720 at 15 fps and 1.5 Mbps with capability-safe browser fallback.
- `MediaSurface` records identify office/room/screen, ACL, source, and state.
- WebRTC SFU track after authorization and explicit viewer opt-in; Three.js gets
a video texture only while subscribed.
- WebRTC peer track after authorization and explicit viewer opt-in; Three.js
gets a video texture only while subscribed. An SFU is a later scale-out
option, not a core requirement for the bounded eight-participant room.
- Share tab/window by default, muted viewer autoplay, visible broadcast state,
presenter kill switch, late join and reconnect.
+28 -3
View File
@@ -32,9 +32,10 @@ tera.example.com {
file_server
header {
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
# Screen sharing remains a per-action browser prompt. Webcam capture stays
# disabled until that separate feature is deliberately deployed.
Permissions-Policy "display-capture=(self), camera=(), microphone=(), geolocation=(), payment=(), usb=()"
# Screen sharing and webcam faces remain contextual, per-action browser
# prompts. Same-origin permission enables those prompts; it does not grant
# capture. Microphone capture is deliberately unavailable.
Permissions-Policy "display-capture=(self), camera=(self), microphone=(), geolocation=(), payment=(), usb=()"
}
}
```
@@ -48,6 +49,30 @@ is drawn on a canvas at runtime rather than shipped as a file — see
ARCHITECTURE.md §3.1 for why that is a licensing decision and not a technical
one.
### Capture-policy acceptance check
Webcam faces require a secure context and `camera=(self)` on both the Tera and
Office hostnames. That policy only permits the application to ask: capture must
still begin inside the explicit face or screen action, display a persistent
active indicator, and stop on the in-product control or `pagehide`. Keep
`microphone=()` because neither webcam faces nor office screens request audio.
After changing the live Caddy site, reload Caddy through the normal reviewed
deployment procedure and verify both hostnames before enabling the UI:
```bash
curl -fsSI https://tera.example.com/ | grep -i '^permissions-policy:'
curl -fsSI https://office.example.com/ | grep -i '^permissions-policy:'
```
Both responses must contain `display-capture=(self)`, `camera=(self)`, and
`microphone=()`. In browser developer tools,
`document.permissionsPolicy?.allowsFeature("camera")` should be true on the
top-level same-origin page; denying the browser prompt must leave the generated
face and every office screen placeholder intact. The CSP needs no camera/media
host exception: webcam and display tracks are caller-owned `MediaStream`s, not
network media URLs, and signaling remains under same-origin `connect-src`.
## Serving the API too
Only needed for live weather, real ADS-B, or markers from an external source.
+1
View File
@@ -988,6 +988,7 @@
<p class="card tier" id="tier" hidden></p>
<div id="presence-host" hidden></div>
<div id="webcam-face-indicator"></div>
<aside id="corner" class="corner" aria-label="Plan view">
<div class="card" id="minimap">
+6
View File
@@ -205,6 +205,10 @@ export interface SceneHandle {
actorState(): Readonly<ActorControllerSnapshot> | null;
/** Replace the visible local actor profile without disturbing its position or controls. */
setActorIdentity(identity: ActorIdentity): void;
/** Attach a caller-owned face texture to the local humanoid, if one is active. */
attachActorFaceTexture(texture: THREE.Texture): boolean;
/** Detach the current local face texture without disposing caller ownership. */
clearActorFaceTexture(): void;
setActorActive(active: boolean): void;
actorActive(): boolean;
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
@@ -646,6 +650,8 @@ export async function createScene(
setActorActions: (actions) => { sceneActor?.setActions(actions); },
actorState: () => sceneActor?.state() ?? null,
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
setActorActive(active) {
sceneActor?.setActive(active);
if (active) {
+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.
+4
View File
@@ -26,6 +26,8 @@ export {
} from "./officeScreenPanel.ts";
export {
createRemoteOfficeMedia,
applyScreenShareEncodingPolicy,
DEFAULT_SCREEN_SHARE_ENCODING_POLICY,
type PresenterStart,
type RemoteMediaConsent,
type RemoteMediaEndpoints,
@@ -35,6 +37,8 @@ export {
type RemoteMediaTimers,
type RemoteOfficeMedia,
type RemoteOfficeMediaOptions,
type ScreenShareEncodingPolicy,
type ScreenShareEncodingPolicyResult,
type ViewerStart,
} from "./remoteMedia.ts";
export * from "./signalingTypes.ts";
+128 -3
View File
@@ -59,6 +59,8 @@ export interface RemoteOfficeMediaOptions {
now?: () => number;
reconnectBaseMs?: number;
reconnectMaximumMs?: number;
/** Bounded sender defaults for every presenter-to-viewer connection. */
presenterEncodingPolicy?: Partial<ScreenShareEncodingPolicy>;
onStateChange?(state: RemoteMediaState): void;
onError?(error: Error): void;
}
@@ -93,10 +95,35 @@ export interface RemoteOfficeMedia {
dispose(): Promise<void>;
}
export interface ScreenShareEncodingPolicy {
maximumWidth: number;
maximumHeight: number;
maximumFrameRate: number;
maximumBitrateBps: number;
contentHint: "detail" | "text";
degradationPreference: RTCDegradationPreference;
}
export interface ScreenShareEncodingPolicyResult {
contentHintApplied: boolean;
senderParametersApplied: boolean;
}
export const DEFAULT_SCREEN_SHARE_ENCODING_POLICY: Readonly<ScreenShareEncodingPolicy> =
Object.freeze({
maximumWidth: 1_280,
maximumHeight: 720,
maximumFrameRate: 15,
maximumBitrateBps: 1_500_000,
contentHint: "detail",
degradationPreference: "maintain-resolution",
});
interface PeerSlot {
readonly id: string;
readonly connection: RTCPeerConnection;
readonly pendingIce: (RTCIceCandidateInit | null)[];
senderPolicyReady: Promise<void>;
}
const ENDPOINTS: RemoteMediaEndpoints = {
join: "/api/v1/media/join",
@@ -136,6 +163,86 @@ function remoteDescription(signal: Extract<ScreenShareSignalPayload, { kind: "sd
return { type: signal.descriptionType, sdp: signal.sdp };
}
function finitePositive(value: number | undefined, fallback: number, name: string): number {
const resolved = value ?? fallback;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new RangeError(`remote media: ${name} must be finite and positive`);
}
return resolved;
}
function resolveEncodingPolicy(value: Partial<ScreenShareEncodingPolicy> | undefined): ScreenShareEncodingPolicy {
const degradationPreference = value?.degradationPreference ??
DEFAULT_SCREEN_SHARE_ENCODING_POLICY.degradationPreference;
if (
degradationPreference !== "balanced" &&
degradationPreference !== "maintain-framerate" &&
degradationPreference !== "maintain-resolution"
) throw new RangeError("remote media: invalid degradation preference");
const contentHint = value?.contentHint ?? DEFAULT_SCREEN_SHARE_ENCODING_POLICY.contentHint;
if (contentHint !== "detail" && contentHint !== "text") {
throw new RangeError("remote media: invalid screen content hint");
}
return {
maximumWidth: finitePositive(value?.maximumWidth, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumWidth, "maximumWidth"),
maximumHeight: finitePositive(value?.maximumHeight, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumHeight, "maximumHeight"),
maximumFrameRate: finitePositive(value?.maximumFrameRate, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumFrameRate, "maximumFrameRate"),
maximumBitrateBps: finitePositive(value?.maximumBitrateBps, DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumBitrateBps, "maximumBitrateBps"),
contentHint,
degradationPreference,
};
}
/** Best-effort WebRTC policy: capability differences never abort signaling. */
export async function applyScreenShareEncodingPolicy(
track: MediaStreamTrack,
sender: RTCRtpSender,
policyValue: Partial<ScreenShareEncodingPolicy> = {},
): Promise<ScreenShareEncodingPolicyResult> {
const policy = resolveEncodingPolicy(policyValue);
let contentHintApplied = false;
try {
track.contentHint = policy.contentHint;
contentHintApplied = track.contentHint === policy.contentHint;
} catch { /* Safari versions without writable contentHint still share safely. */ }
try {
if (typeof sender.getParameters !== "function" || typeof sender.setParameters !== "function") {
return { contentHintApplied, senderParametersApplied: false };
}
const parameters = sender.getParameters();
if (!Array.isArray(parameters.encodings) || parameters.encodings.length === 0) {
return { contentHintApplied, senderParametersApplied: false };
}
let width = 0;
let height = 0;
try {
const settings = track.getSettings?.();
width = typeof settings?.width === "number" ? settings.width : 0;
height = typeof settings?.height === "number" ? settings.height : 0;
} catch { /* Some synthetic/browser tracks do not expose settings. */ }
const scaleResolutionDownBy = Math.max(
1,
width > 0 ? width / policy.maximumWidth : 1,
height > 0 ? height / policy.maximumHeight : 1,
);
for (const encoding of parameters.encodings) {
encoding.maxBitrate = Math.floor(policy.maximumBitrateBps);
encoding.maxFramerate = policy.maximumFrameRate;
encoding.scaleResolutionDownBy = scaleResolutionDownBy;
}
// Older Safari builds omit this dictionary member; do not manufacture it
// because their setParameters implementations may reject unknown fields.
if ("degradationPreference" in parameters) {
parameters.degradationPreference = policy.degradationPreference;
}
await sender.setParameters(parameters);
return { contentHintApplied, senderParametersApplied: true };
} catch {
return { contentHintApplied, senderParametersApplied: false };
}
}
/**
* Capture-free and storage-free by construction. The adapter never invokes a
* media acquisition API, calls `play`, records, uploads media, or exposes the
@@ -153,6 +260,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
const now = options.now ?? Date.now;
const reconnectBase = options.reconnectBaseMs ?? 500;
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
const presenterEncodingPolicy = resolveEncodingPolicy(options.presenterEncodingPolicy);
const provideIce = options.iceConfigurationProvider ?? ((authorization) =>
fetchEphemeralIceConfiguration({
authenticatedFetch: fetcher,
@@ -172,6 +280,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
let cursor: ScreenShareStreamCursor | null = null;
let participants: readonly ScreenShareParticipant[] = [];
let presenter: PresenterStart | null = null;
let presenterOriginalContentHint: string | null = null;
let viewer: ViewerStart | null = null;
let receiverStream: MediaStream | null = null;
let iceConfiguration: EphemeralIceConfiguration | null = null;
@@ -246,6 +355,11 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
releaseReceiver();
iceConfiguration = null;
iceAuthorization = null;
if (presenterOriginalContentHint !== null) {
const track = presenter?.stream.getVideoTracks()[0];
try { if (track) track.contentHint = presenterOriginalContentHint; } catch { /* best effort */ }
presenterOriginalContentHint = null;
}
}
function resetPeerMesh(): void {
@@ -302,7 +416,13 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
throw new Error("remote media: active ICE configuration is required before creating a peer");
}
const connection = makePeer(iceConfiguration.configuration);
const slot: PeerSlot = { id: participant.participantId, connection, pendingIce: [] };
let senderPolicyReady = Promise.resolve();
const slot: PeerSlot = {
id: participant.participantId,
connection,
pendingIce: [],
senderPolicyReady,
};
peers.set(slot.id, slot);
connection.onicecandidate = (event) => {
if (generation !== activeGeneration) return;
@@ -324,7 +444,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
if (options.role === "presenter") {
const track = presenter?.stream.getVideoTracks()[0];
if (!track || track.readyState !== "live") throw new Error("remote media: presenter track ended");
connection.addTrack(track, presenter!.stream);
if (presenterOriginalContentHint === null) presenterOriginalContentHint = track.contentHint;
const sender = connection.addTrack(track, presenter!.stream);
senderPolicyReady = applyScreenShareEncodingPolicy(track, sender, presenterEncodingPolicy).then(() => undefined);
slot.senderPolicyReady = senderPolicyReady;
} else {
connection.addTransceiver("video", { direction: "recvonly" });
connection.ontrack = (event) => {
@@ -343,7 +466,9 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
}
async function offer(participant: ScreenShareParticipant): Promise<void> {
const connection = configurePeer(participant).connection;
const slot = configurePeer(participant);
await slot.senderPolicyReady;
const connection = slot.connection;
const description = await connection.createOffer();
await connection.setLocalDescription(description);
await sendSignal(participant.participantId, sdp(connection.localDescription ?? description));
+11 -1
View File
@@ -46,6 +46,8 @@ export interface ProfileEditor {
open(): ProfileEditorState;
/** Hide without committing or discarding the draft. */
close(): ProfileEditorState;
/** Include caller-mounted contextual controls in this dialog's keyboard trap. */
registerFocusables(elements: readonly HTMLElement[]): void;
dispose(): void;
}
@@ -365,7 +367,9 @@ export function createProfileEditor(options: ProfileEditorOptions): ProfileEdito
return;
}
if (event.key !== "Tab") return;
const enabled = focusOrder.filter((element) => !((element as HTMLButtonElement).disabled));
const enabled = focusOrder.filter(
(element) => !element.hidden && !((element as HTMLButtonElement).disabled),
);
const first = enabled[0];
const last = enabled[enabled.length - 1];
if (!first || !last) return;
@@ -403,6 +407,12 @@ export function createProfileEditor(options: ProfileEditorOptions): ProfileEdito
return snapshot();
},
close,
registerFocusables(elements) {
if (disposed) return;
for (const element of elements) {
if (focusable(element) && !focusOrder.includes(element)) focusOrder.push(element);
}
},
dispose() {
if (disposed) return;
if (isOpen) close();
+16
View File
@@ -65,3 +65,19 @@ export {
type WebcamFaceTextureState,
type WebcamFaceTextureStatus,
} from "./webcamFaceTexture.ts";
export {
createWebcamFacePanel,
type WebcamFacePanel,
type WebcamFacePanelOptions,
type WebcamFacePanelState,
type WebcamFacePanelStatus,
} from "./webcamPanel.ts";
export {
createWebcamCapture,
type WebcamCaptureBinding,
type WebcamCaptureController,
type WebcamCaptureOptions,
type WebcamCaptureStatus,
} from "./webcamCapture.ts";
+116
View File
@@ -0,0 +1,116 @@
/**
* App-facing ownership boundary for ephemeral webcam capture.
*
* It has no direct browser acquisition capability: callers inject `acquire`
* and `createVideo`, then call `start` only from an explicit user action. It
* owns every stream returned by that acquisition and stops every track on
* Stop, cancellation, failure, or disposal. Nothing is recorded or persisted.
*/
export interface WebcamCaptureBinding {
stream: MediaStream;
video: HTMLVideoElement;
}
export type WebcamCaptureStatus = "off" | "requesting" | "active" | "disposed";
export interface WebcamCaptureOptions {
acquire: () => Promise<MediaStream>;
createVideo: () => HTMLVideoElement;
}
export interface WebcamCaptureController {
status(): WebcamCaptureStatus;
binding(): WebcamCaptureBinding | null;
/** Resolves null when Stop/dispose overtakes acquisition or playback. */
start(): Promise<WebcamCaptureBinding | null>;
stop(): void;
dispose(): void;
}
export function createWebcamCapture(options: WebcamCaptureOptions): WebcamCaptureController {
if (typeof options.acquire !== "function" || typeof options.createVideo !== "function") {
throw new RangeError("webcam capture: acquire and createVideo are required");
}
let state: WebcamCaptureStatus = "off";
let current: WebcamCaptureBinding | null = null;
let operation = 0;
function release(binding: WebcamCaptureBinding): void {
for (const track of binding.stream.getTracks()) track.stop();
binding.video.pause();
binding.video.srcObject = null;
binding.video.remove();
}
function stop(): void {
if (state === "disposed") return;
operation += 1;
const binding = current;
current = null;
if (binding) release(binding);
state = "off";
}
return {
status: () => state,
binding: () => current ? { ...current } : null,
async start() {
if (state === "disposed") throw new Error("webcam capture: disposed");
if (state === "requesting" || state === "active") throw new Error("webcam capture: already started");
const started = ++operation;
state = "requesting";
let stream: MediaStream;
try {
stream = await options.acquire();
} catch (error) {
if (started !== operation) return null;
state = "off";
throw error;
}
if (started !== operation) {
for (const track of stream.getTracks()) track.stop();
return null;
}
let video: HTMLVideoElement;
try {
video = options.createVideo();
video.srcObject = stream;
} catch (error) {
for (const track of stream.getTracks()) track.stop();
state = "off";
throw error;
}
const binding = { stream, video };
// Publish ownership before playback yields. A pagehide during a pending
// play promise can now stop the tracks synchronously.
current = binding;
try {
await video.play();
} catch (error) {
if (current === binding) {
current = null;
release(binding);
}
if (started !== operation) return null;
state = "off";
throw error;
}
if (started !== operation || current !== binding) {
if (current === binding) {
current = null;
release(binding);
}
return null;
}
state = "active";
return { ...binding };
},
stop,
dispose() {
if (state === "disposed") return;
stop();
state = "disposed";
},
};
}
+190
View File
@@ -0,0 +1,190 @@
/** Accessible webcam-face controls. This view never acquires or retains media. */
export type WebcamFacePanelStatus = "off" | "requesting" | "active" | "error" | "unsupported";
export interface WebcamFacePanelState {
status: WebcamFacePanelStatus;
message: string;
indicatorVisible: boolean;
}
export interface WebcamFacePanelOptions {
/** Contextual controls, normally mounted inside the character editor. */
container: HTMLElement;
/** Persistent chrome that remains visible after the character editor closes. */
indicatorContainer: HTMLElement;
supported?: boolean;
onStart: () => void | Promise<void>;
onStop: () => void;
}
export interface WebcamFacePanel {
root: HTMLElement;
indicator: HTMLElement;
/** Buttons to include in a containing modal's focus trap. */
focusables: readonly HTMLElement[];
state(): WebcamFacePanelState;
update(status: WebcamFacePanelStatus, message?: string): WebcamFacePanelState;
/** Restore modal keyboard focus after a browser permission prompt closes. */
focusStop(): void;
dispose(): void;
}
const STYLES = `
.tera-webcam-face { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hairline, rgba(255,255,255,.13)); }
.tera-webcam-face__heading { margin: 0 0 4px; color: white; font: 600 13px/1.3 inherit; }
.tera-webcam-face__copy { margin: 0 0 10px; color: var(--ink-2, rgba(255,255,255,.62)); }
.tera-webcam-face__row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.tera-webcam-face__button {
min-height: 40px; padding: 8px 14px; color: var(--ink, white); cursor: pointer;
background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
border-radius: var(--r-sm, 5px); font: inherit;
}
.tera-webcam-face__button:hover:not(:disabled) { background: rgba(255,255,255,.12); }
.tera-webcam-face__button:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; }
.tera-webcam-face__button:disabled { cursor: wait; opacity: .55; }
.tera-webcam-face__status { margin: 0; color: var(--ink-2, rgba(255,255,255,.62)); }
.tera-webcam-face[data-status="active"] .tera-webcam-face__status { color: #8fd7aa; }
.tera-webcam-face[data-status="error"] .tera-webcam-face__status,
.tera-webcam-face[data-status="unsupported"] .tera-webcam-face__status { color: #ffb2aa; }
.tera-webcam-indicator {
position: fixed; top: calc(var(--s4, 16px) + env(safe-area-inset-top) + 4.55rem); right: var(--s4, 16px);
z-index: 12; display: flex; align-items: center; gap: 9px; padding: 8px 10px;
color: #dff8e8; background: rgba(8, 32, 20, .92); border: 1px solid rgba(100, 220, 145, .45);
border-radius: var(--r-sm, 5px); box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.35));
font: 11px/1.4 ui-monospace, "SF Mono", Menlo, monospace;
}
.tera-webcam-indicator__dot { width: 7px; height: 7px; border-radius: 50%; background: #65dc91; box-shadow: 0 0 0 3px rgba(101,220,145,.14); }
.tera-webcam-indicator__stop { min-height: 30px; padding: 4px 8px; color: inherit; background: transparent; border: 1px solid rgba(143,215,170,.4); border-radius: 4px; font: inherit; cursor: pointer; }
.tera-webcam-indicator__stop:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; }
@media (max-width: 600px) { .tera-webcam-indicator { top: calc(var(--s3, 12px) + env(safe-area-inset-top) + 5rem); right: var(--s3, 12px); } }
`;
const DEFAULT_MESSAGES: Record<WebcamFacePanelStatus, string> = {
off: "Camera is off. Nothing is captured or stored.",
requesting: "Waiting for camera permission…",
active: "Live on your humanoid face. Never recorded or uploaded.",
error: "Camera could not start. Check browser permission and try again.",
unsupported: "Camera faces are not supported by this browser.",
};
export function createWebcamFacePanel(options: WebcamFacePanelOptions): WebcamFacePanel {
if (!options.container || typeof options.container.append !== "function" ||
!options.indicatorContainer || typeof options.indicatorContainer.append !== "function") {
throw new RangeError("webcam face panel: valid containers are required");
}
if (typeof options.onStart !== "function" || typeof options.onStop !== "function") {
throw new RangeError("webcam face panel: start and stop handlers are required");
}
const doc = options.container.ownerDocument;
const root = doc.createElement("section");
root.className = "tera-webcam-face";
const style = doc.createElement("style");
style.setAttribute("data-tera-webcam-face-style", "");
style.textContent = STYLES;
root.append(style);
const heading = doc.createElement("h3");
heading.className = "tera-webcam-face__heading";
heading.textContent = "Live camera face";
const copy = doc.createElement("p");
copy.className = "tera-webcam-face__copy";
copy.textContent = "Optionally put your camera on this humanoid for this tab only. It is never saved, recorded, or uploaded.";
const row = doc.createElement("div");
row.className = "tera-webcam-face__row";
const start = doc.createElement("button");
start.type = "button";
start.className = "tera-webcam-face__button";
start.setAttribute("data-action", "start-camera");
start.textContent = "Start camera";
const stop = doc.createElement("button");
stop.type = "button";
stop.className = "tera-webcam-face__button";
stop.setAttribute("data-action", "stop-camera");
stop.textContent = "Stop camera";
const status = doc.createElement("p");
status.className = "tera-webcam-face__status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
row.append(start, stop, status);
root.append(heading, copy, row);
const indicator = doc.createElement("div");
indicator.className = "tera-webcam-indicator";
indicator.setAttribute("role", "region");
indicator.setAttribute("aria-label", "Camera active");
const dot = doc.createElement("span");
dot.className = "tera-webcam-indicator__dot";
dot.setAttribute("aria-hidden", "true");
const indicatorText = doc.createElement("span");
indicatorText.setAttribute("role", "status");
indicatorText.setAttribute("aria-live", "polite");
indicatorText.textContent = "Camera active · local face only";
const indicatorStop = doc.createElement("button");
indicatorStop.type = "button";
indicatorStop.className = "tera-webcam-indicator__stop";
indicatorStop.setAttribute("data-action", "stop-camera-indicator");
indicatorStop.textContent = "Stop";
indicator.append(dot, indicatorText, indicatorStop);
options.container.append(root);
options.indicatorContainer.append(indicator);
let disposed = false;
let current: WebcamFacePanelState = {
status: options.supported === false ? "unsupported" : "off",
message: DEFAULT_MESSAGES[options.supported === false ? "unsupported" : "off"],
indicatorVisible: false,
};
function snapshot(): WebcamFacePanelState { return { ...current }; }
function render(): void {
root.setAttribute("data-status", current.status);
status.textContent = current.message;
const active = current.status === "active";
const requesting = current.status === "requesting";
start.hidden = active;
start.disabled = requesting || current.status === "unsupported";
stop.hidden = !active && !requesting;
indicator.hidden = !current.indicatorVisible;
// Starting capture completes asynchronously. Do not leave keyboard focus on
// the Start button at the moment it becomes hidden; move it to the visible
// in-dialog Stop control so Escape and the modal focus trap keep working.
if (active) stop.focus();
}
function stopIntent(): void {
if (!disposed && (current.status === "active" || current.status === "requesting")) options.onStop();
}
start.addEventListener("click", () => {
if (!disposed && current.status !== "requesting" && current.status !== "active" && current.status !== "unsupported") {
void options.onStart();
}
});
stop.addEventListener("click", stopIntent);
indicatorStop.addEventListener("click", stopIntent);
render();
return {
root,
indicator,
focusables: [start, stop],
state: snapshot,
update(next, message = DEFAULT_MESSAGES[next]) {
if (disposed) return snapshot();
if (!(next in DEFAULT_MESSAGES) || typeof message !== "string") {
throw new RangeError("webcam face panel: invalid state");
}
current = { status: next, message, indicatorVisible: next === "active" };
render();
return snapshot();
},
focusStop() {
if (!disposed && current.status === "active") stop.focus();
},
dispose() {
if (disposed) return;
disposed = true;
root.remove();
indicator.remove();
},
};
}
+36
View File
@@ -170,4 +170,40 @@ describe("city and office actor acceptance handoff", () => {
walker.dispose();
cityActor.dispose();
});
it("moves one caller-owned live face across the office door and appearance rebuilds", () => {
const identity: ActorIdentity = {
id: "member-camera",
displayName: "Camera Member",
authenticated: true,
profile: { appearance: { primaryColor: "#18222d", accentColor: "#55b8c8" } },
};
const cityActor = createSceneActor({ kind: "humanoid", identity });
const walker = createOfficeWalker(officePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
actor: { kind: "humanoid", outfitColor: "#18222d", accentColor: "#55b8c8" },
});
const texture = new THREE.Texture();
let disposals = 0;
texture.addEventListener("dispose", () => { disposals += 1; });
assert.equal(cityActor.attachFaceTexture(texture), true);
assert.equal((cityActor.root.getObjectByName("humanoid.face") as THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicMaterial>).material.map, texture);
cityActor.clearFaceTexture();
assert.equal(walker.attachFaceTexture(texture), true);
walker.setAppearance({ kind: "humanoid", outfitColor: "#53616d", accentColor: "#db6d62" });
assert.equal((walker.root.getObjectByName("humanoid.face") as THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicMaterial>).material.map, texture);
walker.clearFaceTexture();
assert.equal(cityActor.attachFaceTexture(texture), true);
cityActor.setIdentity({
...identity,
profile: { appearance: { primaryColor: "#53616d", accentColor: "#db6d62" } },
});
assert.equal((cityActor.root.getObjectByName("humanoid.face") as THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicMaterial>).material.map, texture);
walker.dispose();
cityActor.dispose();
assert.equal(disposals, 0, "actor adapters never dispose the app-owned live texture");
});
});
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, it } from "node:test";
const staticRunbook = readFileSync(
fileURLToPath(new URL("../../deploy/STATIC.md", import.meta.url)),
"utf8",
);
describe("production media permissions policy", () => {
const policy = staticRunbook.match(/Permissions-Policy\s+"([^"]+)"/)?.[1] ?? "";
it("allows only same-origin contextual camera and display prompts", () => {
assert.match(policy, /(?:^|,\s*)display-capture=\(self\)(?:,|$)/);
assert.match(policy, /(?:^|,\s*)camera=\(self\)(?:,|$)/);
assert.doesNotMatch(policy, /camera=\(\*\)/);
assert.doesNotMatch(policy, /display-capture=\(\*\)/);
});
it("keeps audio and unrelated high-risk capabilities denied", () => {
for (const capability of ["microphone", "geolocation", "payment", "usb"]) {
assert.match(policy, new RegExp(`(?:^|,\\s*)${capability}=\\(\\)(?:,|$)`));
}
});
it("documents denial, indicator, stop, and page lifecycle acceptance", () => {
assert.match(staticRunbook, /denying the browser prompt/i);
assert.match(staticRunbook, /active indicator/i);
assert.match(staticRunbook, /stop on the in-product control or `pagehide`/i);
assert.match(staticRunbook, /neither webcam faces nor office screens request audio/i);
});
});
+22
View File
@@ -225,6 +225,28 @@ describe("profile editor DOM adapter", () => {
assert.equal(saves.length, 1);
});
it("includes visible contextual controls in its focus trap and skips hidden ones", () => {
const { document, editor } = setup();
const cameraStart = document.createElement("button");
const cameraStop = document.createElement("button");
cameraStop.hidden = true;
editor.registerFocusables([
cameraStart as unknown as HTMLElement,
cameraStop as unknown as HTMLElement,
]);
editor.open();
const root = editor.root as unknown as FakeElement;
const first = root.find("data-field", "displayName");
cameraStart.focus();
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
assert.equal(document.activeElement, first);
cameraStart.hidden = true;
const save = root.find("data-action", "save");
save.focus();
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
assert.equal(document.activeElement, first);
});
it("strictly updates caller data, then disposes without leaving DOM", () => {
const { container, editor, previews } = setup();
const next = createDefaultLocalProfile("new-person", "Morgan");
+135 -1
View File
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DEFAULT_SCREEN_SHARE_ENCODING_POLICY,
applyScreenShareEncodingPolicy,
createRemoteOfficeMedia,
type MediaAuthorizationDecision,
type RemoteMediaTimers,
@@ -21,9 +23,40 @@ class FakeTrack {
readonly kind = "video";
readyState: MediaStreamTrackState = "live";
stopped = 0;
contentHint = "";
private readonly settings: MediaTrackSettings;
constructor(settings: MediaTrackSettings = { width: 3_840, height: 2_160, frameRate: 60 }) {
this.settings = settings;
}
getSettings(): MediaTrackSettings { return { ...this.settings }; }
stop(): void { this.stopped += 1; this.readyState = "ended"; }
}
class FakeSender {
readonly writes: RTCRtpSendParameters[] = [];
private readonly rejectWrites: boolean;
private readonly supported: boolean;
constructor(rejectWrites = false, supported = true) {
this.rejectWrites = rejectWrites;
this.supported = supported;
}
getParameters(): RTCRtpSendParameters {
if (!this.supported) throw new Error("getParameters unsupported");
return {
encodings: [{}],
degradationPreference: "balanced",
headerExtensions: [],
codecs: [],
rtcp: { cname: "fake", reducedSize: false },
transactionId: "fake",
};
}
async setParameters(value: RTCRtpSendParameters): Promise<void> {
if (this.rejectWrites) throw new DOMException("unsupported", "NotSupportedError");
this.writes.push(structuredClone(value));
}
}
class FakeStream {
readonly tracks: FakeTrack[];
constructor(tracks: FakeTrack[]) { this.tracks = tracks; }
@@ -48,9 +81,15 @@ class FakePeer {
readonly remote: RTCSessionDescriptionInit[] = [];
readonly ice: (RTCIceCandidateInit | null)[] = [];
readonly transceivers: string[] = [];
readonly senders: FakeSender[] = [];
closed = 0;
addTrack(track: MediaStreamTrack): RTCRtpSender { this.added.push(track); return {} as RTCRtpSender; }
addTrack(track: MediaStreamTrack): RTCRtpSender {
this.added.push(track);
const sender = new FakeSender();
this.senders.push(sender);
return sender as unknown as RTCRtpSender;
}
addTransceiver(kind: string): RTCRtpTransceiver { this.transceivers.push(kind); return {} as RTCRtpTransceiver; }
async createOffer(): Promise<RTCSessionDescriptionInit> { return { type: "offer", sdp: "presenter-offer" }; }
async createAnswer(): Promise<RTCSessionDescriptionInit> { return { type: "answer", sdp: "viewer-answer" }; }
@@ -176,6 +215,41 @@ async function settle(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("screen share sender policy", () => {
it("caps a 4K sender at the explicit 720p/15 bandwidth contract", async () => {
const track = new FakeTrack();
const sender = new FakeSender();
const result = await applyScreenShareEncodingPolicy(
track as unknown as MediaStreamTrack,
sender as unknown as RTCRtpSender,
);
assert.deepEqual(result, { contentHintApplied: true, senderParametersApplied: true });
assert.equal(track.contentHint, "detail");
assert.equal(DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumWidth, 1_280);
assert.equal(DEFAULT_SCREEN_SHARE_ENCODING_POLICY.maximumHeight, 720);
assert.deepEqual(sender.writes[0]?.encodings, [{
maxBitrate: 1_500_000,
maxFramerate: 15,
scaleResolutionDownBy: 3,
}]);
assert.equal(sender.writes[0]?.degradationPreference, "maintain-resolution");
});
it("falls back without throwing when a browser rejects sender parameters", async () => {
const track = new FakeTrack();
const rejected = await applyScreenShareEncodingPolicy(
track as unknown as MediaStreamTrack,
new FakeSender(true) as unknown as RTCRtpSender,
);
assert.deepEqual(rejected, { contentHintApplied: true, senderParametersApplied: false });
const unsupported = await applyScreenShareEncodingPolicy(
track as unknown as MediaStreamTrack,
{} as RTCRtpSender,
);
assert.deepEqual(unsupported, { contentHintApplied: true, senderParametersApplied: false });
});
});
describe("remote office media presenter", () => {
it("requires opt-in, publishes one caller track, and keeps credentials in authenticated POST bodies", async () => {
const presenterTrack = new FakeTrack();
@@ -210,6 +284,7 @@ describe("remote office media presenter", () => {
await settle();
assert.equal(peers.length, 1);
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
assert.equal(peers[0]?.senders[0]?.writes[0]?.encodings[0]?.maxFramerate, 15);
assert.equal(video.autoplay, false);
assert.equal(video.muted, true);
const signal = calls.find((call) => call.url.endsWith("/signal"));
@@ -229,9 +304,68 @@ describe("remote office media presenter", () => {
assert.equal(JSON.stringify(media.state()).includes("private-presenter-grant"), false);
await media.stop();
assert.equal(presenterTrack.stopped, 0, "caller-owned capture track survives stop");
assert.equal(presenterTrack.contentHint, "", "caller-owned track metadata is restored on stop");
assert.equal(peers[0]?.closed, 1);
});
it("applies the bounded sender policy independently to late-joining viewers", async () => {
const first = { participantId: "viewer-first", role: "viewer" as const };
const late = { participantId: "viewer-late", role: "viewer" as const };
const peers: FakePeer[] = [];
const fetcher: typeof fetch = async (input, init = {}) => {
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
const ice = iceResponse(input, body);
if (ice) return ice;
if (String(input).endsWith("/join")) {
return Response.json(grant("screen-share-create-grant", String(body.requestId), "presenter", [first]), { status: 201 });
}
if (String(input).endsWith("/events")) {
return sse([
grant("screen-share-resume-grant", String(body.requestId), "presenter", [first]),
{
type: "screen-share-participants",
protocolVersion: 1,
sequence: 2,
timestampMs: NOW + 1,
sessionId: "share-session",
binding: BINDING,
participants: [first, late],
leaseExpiresAtMs: NOW + 60_000,
},
]);
}
return new Response(null, { status: 204 });
};
const track = new FakeTrack();
const media = createRemoteOfficeMedia({
role: "presenter",
binding: BINDING,
authenticatedFetch: fetcher,
now: () => NOW,
peerConnectionFactory: () => {
const peer = new FakePeer();
peers.push(peer);
return peer as unknown as RTCPeerConnection;
},
});
await media.startPresenter({
consent: { authorized: true, optedIn: true },
stream: new FakeStream([track]) as unknown as MediaStream,
video: new FakeVideo() as unknown as HTMLVideoElement,
});
await settle();
assert.equal(peers.length, 2);
for (const peer of peers) {
assert.equal(peer.added[0], track as unknown as MediaStreamTrack);
assert.equal(peer.senders[0]?.writes.length, 1);
assert.equal(peer.senders[0]?.writes[0]?.encodings[0]?.maxBitrate, 1_500_000);
assert.equal(peer.senders[0]?.writes[0]?.encodings[0]?.scaleResolutionDownBy, 3);
}
await media.stop();
assert.equal(track.stopped, 0);
assert.equal(track.contentHint, "");
});
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
const timers = new FakeTimers();
const peers: FakePeer[] = [];
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createWebcamCapture } from "../profile/index.ts";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
class FakeTrack {
stops = 0;
stop(): void { this.stops += 1; }
}
class FakeStream {
readonly tracks: FakeTrack[];
constructor(tracks: FakeTrack[]) { this.tracks = tracks; }
getTracks(): FakeTrack[] { return this.tracks; }
}
class FakeVideo {
srcObject: MediaStream | null = null;
pauses = 0;
removals = 0;
readonly playing: Promise<void>;
constructor(playing: Promise<void> = Promise.resolve()) { this.playing = playing; }
play(): Promise<void> { return this.playing; }
pause(): void { this.pauses += 1; }
remove(): void { this.removals += 1; }
}
describe("ephemeral webcam capture ownership", () => {
it("stops a permission result that arrives after explicit Stop", async () => {
const acquired = deferred<MediaStream>();
const track = new FakeTrack();
let videos = 0;
const capture = createWebcamCapture({
acquire: () => acquired.promise,
createVideo: () => { videos += 1; return new FakeVideo() as unknown as HTMLVideoElement; },
});
const start = capture.start();
assert.equal(capture.status(), "requesting");
capture.stop();
acquired.resolve(new FakeStream([track]) as unknown as MediaStream);
assert.equal(await start, null);
assert.equal(track.stops, 1);
assert.equal(videos, 0, "cancelled permission never creates a video surface");
assert.equal(capture.binding(), null);
});
it("owns the stream before play settles so pagehide-style Stop is immediate", async () => {
const playing = deferred<void>();
const tracks = [new FakeTrack(), new FakeTrack()];
const video = new FakeVideo(playing.promise);
const capture = createWebcamCapture({
acquire: async () => new FakeStream(tracks) as unknown as MediaStream,
createVideo: () => video as unknown as HTMLVideoElement,
});
const start = capture.start();
await Promise.resolve();
assert.ok(capture.binding(), "pending playback is already caller-owned");
capture.stop();
assert.deepEqual(tracks.map((track) => track.stops), [1, 1]);
assert.equal(video.pauses, 1);
assert.equal(video.srcObject, null);
assert.equal(video.removals, 1);
playing.resolve();
assert.equal(await start, null);
assert.equal(capture.status(), "off");
});
it("releases every active track on Stop and dispose without retaining media", async () => {
const tracks = [new FakeTrack(), new FakeTrack()];
const video = new FakeVideo();
const stream = new FakeStream(tracks) as unknown as MediaStream;
const capture = createWebcamCapture({
acquire: async () => stream,
createVideo: () => video as unknown as HTMLVideoElement,
});
const binding = await capture.start();
assert.equal(binding?.stream, stream);
assert.equal(capture.status(), "active");
const exposed = capture.binding();
assert.notEqual(exposed, binding, "binding snapshots do not expose mutable controller state");
capture.dispose();
capture.dispose();
assert.deepEqual(tracks.map((track) => track.stops), [1, 1]);
assert.equal(capture.binding(), null);
assert.equal(capture.status(), "disposed");
await assert.rejects(capture.start(), /disposed/);
});
});
+145
View File
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createWebcamFacePanel } from "../profile/index.ts";
type Listener = () => void;
class FakeElement {
readonly children: FakeElement[] = [];
readonly attributes = new Map<string, string>();
readonly listeners = new Map<string, Listener[]>();
parentElement: FakeElement | null = null;
className = "";
textContent = "";
hidden = false;
disabled = false;
type = "";
readonly ownerDocument: FakeDocument;
readonly tagName: string;
constructor(ownerDocument: FakeDocument, tagName: string) {
this.ownerDocument = ownerDocument;
this.tagName = tagName.toUpperCase();
}
append(...nodes: FakeElement[]): void {
for (const node of nodes) {
node.parentElement = this;
this.children.push(node);
}
}
setAttribute(name: string, value: string): void { this.attributes.set(name, value); }
getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; }
addEventListener(type: string, listener: Listener): void {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
dispatch(type: string): void { for (const listener of this.listeners.get(type) ?? []) listener(); }
focus(): void { this.ownerDocument.activeElement = this; }
remove(): void {
if (!this.parentElement) return;
const index = this.parentElement.children.indexOf(this);
if (index >= 0) this.parentElement.children.splice(index, 1);
this.parentElement = null;
}
find(attribute: string, value: string): FakeElement {
if (this.attributes.get(attribute) === value) return this;
for (const child of this.children) {
try { return child.find(attribute, value); } catch { /* keep looking */ }
}
throw new Error(`missing [${attribute}=${value}]`);
}
descendants(tagName: string): FakeElement[] {
return this.children.flatMap((child) => [
...(child.tagName === tagName.toUpperCase() ? [child] : []),
...child.descendants(tagName),
]);
}
}
class FakeDocument {
activeElement: FakeElement | null = null;
createElement(tagName: string): FakeElement { return new FakeElement(this, tagName); }
}
function setup(supported = true) {
const document = new FakeDocument();
const container = document.createElement("div");
const indicatorContainer = document.createElement("div");
let starts = 0;
let stops = 0;
const panel = createWebcamFacePanel({
container: container as unknown as HTMLElement,
indicatorContainer: indicatorContainer as unknown as HTMLElement,
supported,
onStart: () => { starts += 1; },
onStop: () => { stops += 1; },
});
return { container, indicatorContainer, panel, starts: () => starts, stops: () => stops };
}
describe("webcam face controls", () => {
it("starts off explicitly off and emits capture intent only from the Start button", () => {
const { panel, starts, stops, indicatorContainer } = setup();
const root = panel.root as unknown as FakeElement;
const start = root.find("data-action", "start-camera");
const stop = root.find("data-action", "stop-camera");
assert.deepEqual(panel.state(), {
status: "off",
message: "Camera is off. Nothing is captured or stored.",
indicatorVisible: false,
});
assert.equal((panel.indicator as unknown as FakeElement).hidden, true);
assert.equal(indicatorContainer.children.length, 1);
stop.dispatch("click");
assert.equal(stops(), 0);
start.dispatch("click");
assert.equal(starts(), 1);
assert.equal(stops(), 0);
});
it("keeps a visible active indicator with one-click Stop outside the dialog", () => {
const { panel, stops } = setup();
const root = panel.root as unknown as FakeElement;
const start = root.find("data-action", "start-camera");
const stop = root.find("data-action", "stop-camera");
start.focus();
panel.update("active");
assert.equal(panel.state().indicatorVisible, true);
assert.equal(root.getAttribute("data-status"), "active");
const indicator = panel.indicator as unknown as FakeElement;
assert.equal(indicator.hidden, false);
assert.equal(indicator.getAttribute("role"), "region");
assert.equal(indicator.getAttribute("aria-label"), "Camera active");
assert.equal(indicator.descendants("span")[1]?.getAttribute("role"), "status");
assert.equal(start.hidden, true);
assert.equal(stop.hidden, false);
assert.equal(start.ownerDocument.activeElement, stop, "focus follows the asynchronous state change");
indicator.find("data-action", "stop-camera-indicator").dispatch("click");
assert.equal(stops(), 1);
});
it("handles requesting, denial text, unsupported browsers, and disposal defensively", () => {
const first = setup();
const start = (first.panel.root as unknown as FakeElement).find("data-action", "start-camera");
first.panel.update("requesting");
assert.equal(start.disabled, true);
start.dispatch("click");
assert.equal(first.starts(), 0);
const denial = "Camera permission was denied. Nothing was captured.";
first.panel.update("error", denial);
assert.equal(first.panel.state().message, denial);
const unsupported = setup(false);
const unsupportedStart = (unsupported.panel.root as unknown as FakeElement).find("data-action", "start-camera");
assert.equal(unsupported.panel.state().status, "unsupported");
assert.equal(unsupportedStart.disabled, true);
unsupportedStart.dispatch("click");
assert.equal(unsupported.starts(), 0);
assert.throws(() => first.panel.update("bogus" as never), RangeError);
first.panel.dispose();
first.panel.dispose();
assert.equal(first.container.children.length, 0);
assert.equal(first.indicatorContainer.children.length, 0);
});
});