feat: add ephemeral webcam faces and adaptive screens
This commit is contained in:
+128
-3
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user