feat: add ephemeral webcam faces and adaptive screens
This commit is contained in:
+16
-2
@@ -120,6 +120,12 @@ rejected; selected cell concurrency passes a repeatable load test.
|
|||||||
|
|
||||||
## M5 — profile and webcam faces
|
## 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.
|
- Generated profile face is the default.
|
||||||
- Webcam face is opt-in and ephemeral: explicit contextual permission, active
|
- Webcam face is opt-in and ephemeral: explicit contextual permission, active
|
||||||
indicator, one-click stop, no recording/storage by default.
|
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
|
## 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.
|
- `MediaSurface` records identify office/room/screen, ACL, source, and state.
|
||||||
- WebRTC SFU track after authorization and explicit viewer opt-in; Three.js gets
|
- WebRTC peer track after authorization and explicit viewer opt-in; Three.js
|
||||||
a video texture only while subscribed.
|
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,
|
- Share tab/window by default, muted viewer autoplay, visible broadcast state,
|
||||||
presenter kill switch, late join and reconnect.
|
presenter kill switch, late join and reconnect.
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -32,9 +32,10 @@ tera.example.com {
|
|||||||
file_server
|
file_server
|
||||||
header {
|
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'"
|
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
|
# Screen sharing and webcam faces remain contextual, per-action browser
|
||||||
# disabled until that separate feature is deliberately deployed.
|
# prompts. Same-origin permission enables those prompts; it does not grant
|
||||||
Permissions-Policy "display-capture=(self), camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
# 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
|
ARCHITECTURE.md §3.1 for why that is a licensing decision and not a technical
|
||||||
one.
|
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
|
## Serving the API too
|
||||||
|
|
||||||
Only needed for live weather, real ADS-B, or markers from an external source.
|
Only needed for live weather, real ADS-B, or markers from an external source.
|
||||||
|
|||||||
@@ -988,6 +988,7 @@
|
|||||||
|
|
||||||
<p class="card tier" id="tier" hidden></p>
|
<p class="card tier" id="tier" hidden></p>
|
||||||
<div id="presence-host" hidden></div>
|
<div id="presence-host" hidden></div>
|
||||||
|
<div id="webcam-face-indicator"></div>
|
||||||
|
|
||||||
<aside id="corner" class="corner" aria-label="Plan view">
|
<aside id="corner" class="corner" aria-label="Plan view">
|
||||||
<div class="card" id="minimap">
|
<div class="card" id="minimap">
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ export interface SceneHandle {
|
|||||||
actorState(): Readonly<ActorControllerSnapshot> | null;
|
actorState(): Readonly<ActorControllerSnapshot> | null;
|
||||||
/** Replace the visible local actor profile without disturbing its position or controls. */
|
/** Replace the visible local actor profile without disturbing its position or controls. */
|
||||||
setActorIdentity(identity: ActorIdentity): void;
|
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;
|
setActorActive(active: boolean): void;
|
||||||
actorActive(): boolean;
|
actorActive(): boolean;
|
||||||
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
|
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
|
||||||
@@ -646,6 +650,8 @@ export async function createScene(
|
|||||||
setActorActions: (actions) => { sceneActor?.setActions(actions); },
|
setActorActions: (actions) => { sceneActor?.setActions(actions); },
|
||||||
actorState: () => sceneActor?.state() ?? null,
|
actorState: () => sceneActor?.state() ?? null,
|
||||||
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
|
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
|
||||||
|
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
|
||||||
|
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
|
||||||
setActorActive(active) {
|
setActorActive(active) {
|
||||||
sceneActor?.setActive(active);
|
sceneActor?.setActive(active);
|
||||||
if (active) {
|
if (active) {
|
||||||
|
|||||||
+126
-1
@@ -73,11 +73,18 @@ import {
|
|||||||
actorKindForPresence,
|
actorKindForPresence,
|
||||||
createProfileEditor,
|
createProfileEditor,
|
||||||
createDefaultLocalProfile,
|
createDefaultLocalProfile,
|
||||||
|
createWebcamCapture,
|
||||||
|
createWebcamFaceConsent,
|
||||||
|
createWebcamFacePanel,
|
||||||
|
createWebcamFaceTexture,
|
||||||
loadLocalProfile,
|
loadLocalProfile,
|
||||||
resolveHumanoidAppearance,
|
resolveHumanoidAppearance,
|
||||||
saveLocalProfile,
|
saveLocalProfile,
|
||||||
type LocalProfile,
|
type LocalProfile,
|
||||||
type ProfileEditor,
|
type ProfileEditor,
|
||||||
|
type WebcamCaptureController,
|
||||||
|
type WebcamFacePanel,
|
||||||
|
type WebcamFaceTextureAdapter,
|
||||||
} from "./profile/index.ts";
|
} from "./profile/index.ts";
|
||||||
import type { ActorIdentity } from "./actors/controller.ts";
|
import type { ActorIdentity } from "./actors/controller.ts";
|
||||||
import { SRGBColorSpace, VideoTexture } from "three";
|
import { SRGBColorSpace, VideoTexture } from "three";
|
||||||
@@ -964,6 +971,7 @@ async function mountCity(id: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
city = handle;
|
city = handle;
|
||||||
|
attachCurrentWebcamFace();
|
||||||
moveRealtimePresence();
|
moveRealtimePresence();
|
||||||
// A new board builds a new layer, and a new layer starts visible. Reapply
|
// 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
|
// whatever the panel last said, or the setting silently undoes itself on the
|
||||||
@@ -1282,6 +1290,7 @@ async function enterOffice() {
|
|||||||
}
|
}
|
||||||
city.stage.setScene(office);
|
city.stage.setScene(office);
|
||||||
inside = true;
|
inside = true;
|
||||||
|
attachCurrentWebcamFace();
|
||||||
moveRealtimePresence();
|
moveRealtimePresence();
|
||||||
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
|
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
|
||||||
journeyToCity(desiredCity);
|
journeyToCity(desiredCity);
|
||||||
@@ -1408,6 +1417,7 @@ function leaveOffice() {
|
|||||||
dispatchJourney({ type: "leave-office" });
|
dispatchJourney({ type: "leave-office" });
|
||||||
city.stage.setScene(city.stageScene);
|
city.stage.setScene(city.stageScene);
|
||||||
inside = false;
|
inside = false;
|
||||||
|
attachCurrentWebcamFace();
|
||||||
moveRealtimePresence();
|
moveRealtimePresence();
|
||||||
showPlan();
|
showPlan();
|
||||||
showDetail(null);
|
showDetail(null);
|
||||||
@@ -1587,8 +1597,13 @@ const screensButton = document.querySelector<HTMLButtonElement>("#screens");
|
|||||||
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
||||||
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
||||||
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
||||||
|
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
|
||||||
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
|
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
|
||||||
let profileEditor: ProfileEditor | null = null;
|
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) {
|
function showDetail(text: string | null) {
|
||||||
const card = document.querySelector<HTMLElement>("#detail");
|
const card = document.querySelector<HTMLElement>("#detail");
|
||||||
@@ -2116,6 +2131,7 @@ function clearRealtimePeers(): void {
|
|||||||
|
|
||||||
function applyRealtimeMessage(message: ServerRealtimeMessage): void {
|
function applyRealtimeMessage(message: ServerRealtimeMessage): void {
|
||||||
if (message.type === "membership-revoked") {
|
if (message.type === "membership-revoked") {
|
||||||
|
stopWebcamFace(true);
|
||||||
clearRealtimePeers();
|
clearRealtimePeers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2236,6 +2252,7 @@ async function initializeRealtimePresence(): Promise<void> {
|
|||||||
|
|
||||||
window.addEventListener("pagehide", () => {
|
window.addEventListener("pagehide", () => {
|
||||||
realtimePageActive = false;
|
realtimePageActive = false;
|
||||||
|
stopWebcamFace(true);
|
||||||
disposeOfficeScreenUi();
|
disposeOfficeScreenUi();
|
||||||
realtimeOperation += 1;
|
realtimeOperation += 1;
|
||||||
stopRealtimeSubscription?.();
|
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 {
|
function ensureProfileEditor(): ProfileEditor | null {
|
||||||
if (profileEditor) return profileEditor;
|
if (profileEditor) return profileEditor;
|
||||||
if (!profileOverlay || !localProfile || access.subject === null) return null;
|
if (!profileOverlay || !localProfile || access.subject === null) return null;
|
||||||
@@ -2297,6 +2404,19 @@ function ensureProfileEditor(): ProfileEditor | null {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
|
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;
|
return profileEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2526,7 +2646,12 @@ async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<v
|
|||||||
try {
|
try {
|
||||||
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
|
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
|
||||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
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,
|
audio: false,
|
||||||
// Chromium honours these as chooser preferences. Other browsers ignore
|
// Chromium honours these as chooser preferences. Other browsers ignore
|
||||||
// unknown dictionary members and still require the same explicit prompt.
|
// unknown dictionary members and still require the same explicit prompt.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export {
|
|||||||
} from "./officeScreenPanel.ts";
|
} from "./officeScreenPanel.ts";
|
||||||
export {
|
export {
|
||||||
createRemoteOfficeMedia,
|
createRemoteOfficeMedia,
|
||||||
|
applyScreenShareEncodingPolicy,
|
||||||
|
DEFAULT_SCREEN_SHARE_ENCODING_POLICY,
|
||||||
type PresenterStart,
|
type PresenterStart,
|
||||||
type RemoteMediaConsent,
|
type RemoteMediaConsent,
|
||||||
type RemoteMediaEndpoints,
|
type RemoteMediaEndpoints,
|
||||||
@@ -35,6 +37,8 @@ export {
|
|||||||
type RemoteMediaTimers,
|
type RemoteMediaTimers,
|
||||||
type RemoteOfficeMedia,
|
type RemoteOfficeMedia,
|
||||||
type RemoteOfficeMediaOptions,
|
type RemoteOfficeMediaOptions,
|
||||||
|
type ScreenShareEncodingPolicy,
|
||||||
|
type ScreenShareEncodingPolicyResult,
|
||||||
type ViewerStart,
|
type ViewerStart,
|
||||||
} from "./remoteMedia.ts";
|
} from "./remoteMedia.ts";
|
||||||
export * from "./signalingTypes.ts";
|
export * from "./signalingTypes.ts";
|
||||||
|
|||||||
+128
-3
@@ -59,6 +59,8 @@ export interface RemoteOfficeMediaOptions {
|
|||||||
now?: () => number;
|
now?: () => number;
|
||||||
reconnectBaseMs?: number;
|
reconnectBaseMs?: number;
|
||||||
reconnectMaximumMs?: number;
|
reconnectMaximumMs?: number;
|
||||||
|
/** Bounded sender defaults for every presenter-to-viewer connection. */
|
||||||
|
presenterEncodingPolicy?: Partial<ScreenShareEncodingPolicy>;
|
||||||
onStateChange?(state: RemoteMediaState): void;
|
onStateChange?(state: RemoteMediaState): void;
|
||||||
onError?(error: Error): void;
|
onError?(error: Error): void;
|
||||||
}
|
}
|
||||||
@@ -93,10 +95,35 @@ export interface RemoteOfficeMedia {
|
|||||||
dispose(): Promise<void>;
|
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 {
|
interface PeerSlot {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly connection: RTCPeerConnection;
|
readonly connection: RTCPeerConnection;
|
||||||
readonly pendingIce: (RTCIceCandidateInit | null)[];
|
readonly pendingIce: (RTCIceCandidateInit | null)[];
|
||||||
|
senderPolicyReady: Promise<void>;
|
||||||
}
|
}
|
||||||
const ENDPOINTS: RemoteMediaEndpoints = {
|
const ENDPOINTS: RemoteMediaEndpoints = {
|
||||||
join: "/api/v1/media/join",
|
join: "/api/v1/media/join",
|
||||||
@@ -136,6 +163,86 @@ function remoteDescription(signal: Extract<ScreenShareSignalPayload, { kind: "sd
|
|||||||
return { type: signal.descriptionType, sdp: signal.sdp };
|
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
|
* Capture-free and storage-free by construction. The adapter never invokes a
|
||||||
* media acquisition API, calls `play`, records, uploads media, or exposes the
|
* 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 now = options.now ?? Date.now;
|
||||||
const reconnectBase = options.reconnectBaseMs ?? 500;
|
const reconnectBase = options.reconnectBaseMs ?? 500;
|
||||||
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
|
const reconnectMaximum = options.reconnectMaximumMs ?? 15_000;
|
||||||
|
const presenterEncodingPolicy = resolveEncodingPolicy(options.presenterEncodingPolicy);
|
||||||
const provideIce = options.iceConfigurationProvider ?? ((authorization) =>
|
const provideIce = options.iceConfigurationProvider ?? ((authorization) =>
|
||||||
fetchEphemeralIceConfiguration({
|
fetchEphemeralIceConfiguration({
|
||||||
authenticatedFetch: fetcher,
|
authenticatedFetch: fetcher,
|
||||||
@@ -172,6 +280,7 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
let cursor: ScreenShareStreamCursor | null = null;
|
let cursor: ScreenShareStreamCursor | null = null;
|
||||||
let participants: readonly ScreenShareParticipant[] = [];
|
let participants: readonly ScreenShareParticipant[] = [];
|
||||||
let presenter: PresenterStart | null = null;
|
let presenter: PresenterStart | null = null;
|
||||||
|
let presenterOriginalContentHint: string | null = null;
|
||||||
let viewer: ViewerStart | null = null;
|
let viewer: ViewerStart | null = null;
|
||||||
let receiverStream: MediaStream | null = null;
|
let receiverStream: MediaStream | null = null;
|
||||||
let iceConfiguration: EphemeralIceConfiguration | null = null;
|
let iceConfiguration: EphemeralIceConfiguration | null = null;
|
||||||
@@ -246,6 +355,11 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
releaseReceiver();
|
releaseReceiver();
|
||||||
iceConfiguration = null;
|
iceConfiguration = null;
|
||||||
iceAuthorization = 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 {
|
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");
|
throw new Error("remote media: active ICE configuration is required before creating a peer");
|
||||||
}
|
}
|
||||||
const connection = makePeer(iceConfiguration.configuration);
|
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);
|
peers.set(slot.id, slot);
|
||||||
connection.onicecandidate = (event) => {
|
connection.onicecandidate = (event) => {
|
||||||
if (generation !== activeGeneration) return;
|
if (generation !== activeGeneration) return;
|
||||||
@@ -324,7 +444,10 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
if (options.role === "presenter") {
|
if (options.role === "presenter") {
|
||||||
const track = presenter?.stream.getVideoTracks()[0];
|
const track = presenter?.stream.getVideoTracks()[0];
|
||||||
if (!track || track.readyState !== "live") throw new Error("remote media: presenter track ended");
|
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 {
|
} else {
|
||||||
connection.addTransceiver("video", { direction: "recvonly" });
|
connection.addTransceiver("video", { direction: "recvonly" });
|
||||||
connection.ontrack = (event) => {
|
connection.ontrack = (event) => {
|
||||||
@@ -343,7 +466,9 @@ export function createRemoteOfficeMedia(options: RemoteOfficeMediaOptions): Remo
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function offer(participant: ScreenShareParticipant): Promise<void> {
|
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();
|
const description = await connection.createOffer();
|
||||||
await connection.setLocalDescription(description);
|
await connection.setLocalDescription(description);
|
||||||
await sendSignal(participant.participantId, sdp(connection.localDescription ?? description));
|
await sendSignal(participant.participantId, sdp(connection.localDescription ?? description));
|
||||||
|
|||||||
+11
-1
@@ -46,6 +46,8 @@ export interface ProfileEditor {
|
|||||||
open(): ProfileEditorState;
|
open(): ProfileEditorState;
|
||||||
/** Hide without committing or discarding the draft. */
|
/** Hide without committing or discarding the draft. */
|
||||||
close(): ProfileEditorState;
|
close(): ProfileEditorState;
|
||||||
|
/** Include caller-mounted contextual controls in this dialog's keyboard trap. */
|
||||||
|
registerFocusables(elements: readonly HTMLElement[]): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +367,9 @@ export function createProfileEditor(options: ProfileEditorOptions): ProfileEdito
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key !== "Tab") 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 first = enabled[0];
|
||||||
const last = enabled[enabled.length - 1];
|
const last = enabled[enabled.length - 1];
|
||||||
if (!first || !last) return;
|
if (!first || !last) return;
|
||||||
@@ -403,6 +407,12 @@ export function createProfileEditor(options: ProfileEditorOptions): ProfileEdito
|
|||||||
return snapshot();
|
return snapshot();
|
||||||
},
|
},
|
||||||
close,
|
close,
|
||||||
|
registerFocusables(elements) {
|
||||||
|
if (disposed) return;
|
||||||
|
for (const element of elements) {
|
||||||
|
if (focusable(element) && !focusOrder.includes(element)) focusOrder.push(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (isOpen) close();
|
if (isOpen) close();
|
||||||
|
|||||||
@@ -65,3 +65,19 @@ export {
|
|||||||
type WebcamFaceTextureState,
|
type WebcamFaceTextureState,
|
||||||
type WebcamFaceTextureStatus,
|
type WebcamFaceTextureStatus,
|
||||||
} from "./webcamFaceTexture.ts";
|
} 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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -170,4 +170,40 @@ describe("city and office actor acceptance handoff", () => {
|
|||||||
walker.dispose();
|
walker.dispose();
|
||||||
cityActor.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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -225,6 +225,28 @@ describe("profile editor DOM adapter", () => {
|
|||||||
assert.equal(saves.length, 1);
|
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", () => {
|
it("strictly updates caller data, then disposes without leaving DOM", () => {
|
||||||
const { container, editor, previews } = setup();
|
const { container, editor, previews } = setup();
|
||||||
const next = createDefaultLocalProfile("new-person", "Morgan");
|
const next = createDefaultLocalProfile("new-person", "Morgan");
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_SCREEN_SHARE_ENCODING_POLICY,
|
||||||
|
applyScreenShareEncodingPolicy,
|
||||||
createRemoteOfficeMedia,
|
createRemoteOfficeMedia,
|
||||||
type MediaAuthorizationDecision,
|
type MediaAuthorizationDecision,
|
||||||
type RemoteMediaTimers,
|
type RemoteMediaTimers,
|
||||||
@@ -21,9 +23,40 @@ class FakeTrack {
|
|||||||
readonly kind = "video";
|
readonly kind = "video";
|
||||||
readyState: MediaStreamTrackState = "live";
|
readyState: MediaStreamTrackState = "live";
|
||||||
stopped = 0;
|
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"; }
|
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 {
|
class FakeStream {
|
||||||
readonly tracks: FakeTrack[];
|
readonly tracks: FakeTrack[];
|
||||||
constructor(tracks: FakeTrack[]) { this.tracks = tracks; }
|
constructor(tracks: FakeTrack[]) { this.tracks = tracks; }
|
||||||
@@ -48,9 +81,15 @@ class FakePeer {
|
|||||||
readonly remote: RTCSessionDescriptionInit[] = [];
|
readonly remote: RTCSessionDescriptionInit[] = [];
|
||||||
readonly ice: (RTCIceCandidateInit | null)[] = [];
|
readonly ice: (RTCIceCandidateInit | null)[] = [];
|
||||||
readonly transceivers: string[] = [];
|
readonly transceivers: string[] = [];
|
||||||
|
readonly senders: FakeSender[] = [];
|
||||||
closed = 0;
|
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; }
|
addTransceiver(kind: string): RTCRtpTransceiver { this.transceivers.push(kind); return {} as RTCRtpTransceiver; }
|
||||||
async createOffer(): Promise<RTCSessionDescriptionInit> { return { type: "offer", sdp: "presenter-offer" }; }
|
async createOffer(): Promise<RTCSessionDescriptionInit> { return { type: "offer", sdp: "presenter-offer" }; }
|
||||||
async createAnswer(): Promise<RTCSessionDescriptionInit> { return { type: "answer", sdp: "viewer-answer" }; }
|
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));
|
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", () => {
|
describe("remote office media presenter", () => {
|
||||||
it("requires opt-in, publishes one caller track, and keeps credentials in authenticated POST bodies", async () => {
|
it("requires opt-in, publishes one caller track, and keeps credentials in authenticated POST bodies", async () => {
|
||||||
const presenterTrack = new FakeTrack();
|
const presenterTrack = new FakeTrack();
|
||||||
@@ -210,6 +284,7 @@ describe("remote office media presenter", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
assert.equal(peers.length, 1);
|
assert.equal(peers.length, 1);
|
||||||
assert.equal(peers[0]?.added[0], presenterTrack as unknown as MediaStreamTrack);
|
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.autoplay, false);
|
||||||
assert.equal(video.muted, true);
|
assert.equal(video.muted, true);
|
||||||
const signal = calls.find((call) => call.url.endsWith("/signal"));
|
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);
|
assert.equal(JSON.stringify(media.state()).includes("private-presenter-grant"), false);
|
||||||
await media.stop();
|
await media.stop();
|
||||||
assert.equal(presenterTrack.stopped, 0, "caller-owned capture track survives 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);
|
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 () => {
|
it("tears down failed peers and rebuilds them from a resumed participant snapshot", async () => {
|
||||||
const timers = new FakeTimers();
|
const timers = new FakeTimers();
|
||||||
const peers: FakePeer[] = [];
|
const peers: FakePeer[] = [];
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user