feat(quality): implement strict v13 quality shadow (#291)
This commit is contained in:
1 parent
5a081fb119
commit
501df27bce
22 files changed
+1467
-203
No files matched your search
@@ -116,6 +116,7 @@ export type ViewerPresentationAction =
|
||||
}
|
||||
| { type: "media-bound"; generation: number; revision: number }
|
||||
| { type: "frame-presented"; generation: number; revision: number }
|
||||
| { type: "frame-proof-reset"; generation: number; revision: number }
|
||||
| { type: "autoplay-blocked"; generation: number; revision: number }
|
||||
| { type: "autoplay-cleared"; generation: number }
|
||||
| { type: "playback-failed"; generation: number; revision: number }
|
||||
@@ -210,8 +211,7 @@ export function reduceViewerPresentation(
|
||||
kind: action.kind,
|
||||
},
|
||||
routeStatus:
|
||||
state.routeStatus?.revision === action.revision &&
|
||||
state.routeStatus.state === "waiting"
|
||||
state.routeStatus?.revision === action.revision
|
||||
? state.routeStatus
|
||||
: null,
|
||||
media: preserveMedia
|
||||
@@ -288,7 +288,11 @@ export function reduceViewerPresentation(
|
||||
case "media-bound": {
|
||||
if (
|
||||
(state.revision !== null && action.revision < state.revision) ||
|
||||
(state.media && action.generation <= state.media.generation)
|
||||
(state.media && action.generation <= state.media.generation) ||
|
||||
(state.routeStatus?.revision === action.revision &&
|
||||
state.routeStatus.state === "failed") ||
|
||||
(state.revision === action.revision &&
|
||||
state.failure === "ROUTE_EXHAUSTED")
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
@@ -315,7 +319,9 @@ export function reduceViewerPresentation(
|
||||
!state.media ||
|
||||
state.media.generation !== action.generation ||
|
||||
state.media.revision !== action.revision ||
|
||||
state.revision !== action.revision
|
||||
state.revision !== action.revision ||
|
||||
state.routeStatus?.state === "failed" ||
|
||||
state.failure === "ROUTE_EXHAUSTED"
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
@@ -326,10 +332,22 @@ export function reduceViewerPresentation(
|
||||
retainedFrame: false,
|
||||
routeStatus: null,
|
||||
failure:
|
||||
state.failure === "PLAYBACK_FAILED" ||
|
||||
state.failure === "ROUTE_EXHAUSTED"
|
||||
? null
|
||||
: state.failure,
|
||||
state.failure === "PLAYBACK_FAILED" ? null : state.failure,
|
||||
};
|
||||
case "frame-proof-reset":
|
||||
if (
|
||||
!state.media ||
|
||||
state.media.generation !== action.generation ||
|
||||
state.media.revision !== action.revision ||
|
||||
state.revision !== action.revision ||
|
||||
!state.media.framePresented
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
media: { ...state.media, framePresented: false },
|
||||
retainedFrame: true,
|
||||
};
|
||||
case "autoplay-blocked":
|
||||
if (
|
||||
@@ -540,6 +558,15 @@ export function deriveViewerPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
if (state.routeStatus?.state === "failed") {
|
||||
return presentation(
|
||||
"route-failed",
|
||||
"没有可用的媒体线路",
|
||||
frameOverlay,
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
switch (state.failure) {
|
||||
case "ROUTE_EXHAUSTED":
|
||||
return presentation(
|
||||
@@ -607,6 +634,18 @@ export function deriveViewerPresentation(
|
||||
state,
|
||||
);
|
||||
}
|
||||
if (
|
||||
state.signal === "reconnecting" ||
|
||||
state.connection === "reconnecting" ||
|
||||
state.connection === "failed"
|
||||
) {
|
||||
return presentation(
|
||||
"recovering",
|
||||
"正在恢复连接",
|
||||
frameOverlay,
|
||||
state,
|
||||
);
|
||||
}
|
||||
if (state.media && state.media.revision === state.revision) {
|
||||
return presentation(
|
||||
"receiving",
|
||||
@@ -623,18 +662,6 @@ export function deriveViewerPresentation(
|
||||
state,
|
||||
);
|
||||
}
|
||||
if (
|
||||
state.signal === "reconnecting" ||
|
||||
state.connection === "reconnecting" ||
|
||||
state.connection === "failed"
|
||||
) {
|
||||
return presentation(
|
||||
"recovering",
|
||||
"正在恢复连接",
|
||||
frameOverlay,
|
||||
state,
|
||||
);
|
||||
}
|
||||
if (state.host === "stopped" || state.host === "unknown") {
|
||||
return presentation(
|
||||
"waiting-host",
|
||||
|
||||
@@ -96,10 +96,6 @@ export function qualityEvidenceWindowFromMetrics(
|
||||
width !== null && height !== null
|
||||
? { width, height }
|
||||
: { width: null, height: null };
|
||||
const freezeDuration = boundedNumber(
|
||||
metrics.intervalFreezeDurationMs,
|
||||
windowMs,
|
||||
);
|
||||
const candidate: ViewerQualityEvidenceMetrics = {
|
||||
...dimensions,
|
||||
framesPerSecond: boundedNumber(metrics.framesPerSecond, 240),
|
||||
@@ -120,8 +116,22 @@ export function qualityEvidenceWindowFromMetrics(
|
||||
10_000,
|
||||
),
|
||||
decodeMsPerFrame: boundedNumber(metrics.intervalDecodeMs, 60_000),
|
||||
freezeCountDelta: boundedInteger(metrics.intervalFreezeCount, 10_000),
|
||||
freezeDurationMsDelta: freezeDuration,
|
||||
freezeCountDelta: boundedInteger(
|
||||
metrics.intervalFreezeCount,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
freezeDurationMsDelta: boundedNumber(
|
||||
metrics.intervalFreezeDurationMs,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
pauseCountDelta: boundedInteger(
|
||||
metrics.intervalPauseCount,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
pauseDurationMsDelta: boundedNumber(
|
||||
metrics.intervalPauseDurationMs,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
codec: boundedString(
|
||||
metrics.codec,
|
||||
64,
|
||||
@@ -175,7 +185,9 @@ export function qualityEvidenceWindowFromMetrics(
|
||||
|
||||
export class ViewerQualityEvidenceReporter {
|
||||
private connectionId: string | null = null;
|
||||
private routeRevision: number | null = null;
|
||||
private presentationEpoch = 0;
|
||||
private presentationEligible = false;
|
||||
private baselinePending = true;
|
||||
private sequence = 0;
|
||||
private lastSentAtMs: number | null = null;
|
||||
private lastSampleTimestampMs: number | null = null;
|
||||
@@ -185,11 +197,16 @@ export class ViewerQualityEvidenceReporter {
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
offer(snapshot: PeerSnapshot, routeRevision: number): boolean {
|
||||
offer(
|
||||
snapshot: PeerSnapshot,
|
||||
routeRevision: number,
|
||||
presentationEligible: boolean,
|
||||
): boolean {
|
||||
return this.offerMetrics(
|
||||
snapshot.connectionId,
|
||||
snapshot.metrics,
|
||||
routeRevision,
|
||||
presentationEligible,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,6 +214,7 @@ export class ViewerQualityEvidenceReporter {
|
||||
connectionId: string,
|
||||
metrics: ConnectionMetrics,
|
||||
routeRevision: number,
|
||||
presentationEligible: boolean,
|
||||
): boolean {
|
||||
if (
|
||||
!Number.isSafeInteger(routeRevision) ||
|
||||
@@ -205,8 +223,14 @@ export class ViewerQualityEvidenceReporter {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const window = qualityEvidenceWindowFromMetrics(metrics);
|
||||
const sampleTimestampMs = metrics.sampleTimestampMs;
|
||||
if (this.connectionId !== connectionId) {
|
||||
this.connectionId = connectionId;
|
||||
this.sequence = 0;
|
||||
this.lastSampleTimestampMs = null;
|
||||
this.baselinePending = true;
|
||||
}
|
||||
const window = qualityEvidenceWindowFromMetrics(metrics);
|
||||
if (
|
||||
!window ||
|
||||
sampleTimestampMs === null ||
|
||||
@@ -215,27 +239,36 @@ export class ViewerQualityEvidenceReporter {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (this.connectionId !== connectionId) {
|
||||
this.connectionId = connectionId;
|
||||
this.routeRevision = routeRevision;
|
||||
this.sequence = 0;
|
||||
this.lastSentAtMs = null;
|
||||
this.lastSampleTimestampMs = null;
|
||||
} else if (this.routeRevision !== routeRevision) {
|
||||
this.routeRevision = routeRevision;
|
||||
this.lastSentAtMs = null;
|
||||
this.lastSampleTimestampMs = null;
|
||||
}
|
||||
if (this.sequence > Number.MAX_SAFE_INTEGER) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.lastSampleTimestampMs !== null &&
|
||||
sampleTimestampMs <= this.lastSampleTimestampMs
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.lastSampleTimestampMs = sampleTimestampMs;
|
||||
if (!presentationEligible) {
|
||||
if (this.presentationEligible) {
|
||||
this.invalidatePresentation();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!this.presentationEligible) {
|
||||
this.presentationEligible = true;
|
||||
this.baselinePending = true;
|
||||
}
|
||||
const decodedProgress =
|
||||
metrics.intervalFramesDecoded !== null &&
|
||||
metrics.intervalFramesDecoded > 0;
|
||||
if (!decodedProgress) {
|
||||
return false;
|
||||
}
|
||||
if (this.baselinePending) {
|
||||
this.baselinePending = false;
|
||||
return false;
|
||||
}
|
||||
if (this.sequence > Number.MAX_SAFE_INTEGER) {
|
||||
return false;
|
||||
}
|
||||
const now = this.now();
|
||||
if (
|
||||
!Number.isFinite(now) ||
|
||||
@@ -250,6 +283,7 @@ export class ViewerQualityEvidenceReporter {
|
||||
guard: {
|
||||
connectionId,
|
||||
routeRevision,
|
||||
presentationEpoch: this.presentationEpoch,
|
||||
},
|
||||
sequence: this.sequence,
|
||||
...window,
|
||||
@@ -266,13 +300,26 @@ export class ViewerQualityEvidenceReporter {
|
||||
|
||||
this.sequence += 1;
|
||||
this.lastSentAtMs = now;
|
||||
this.lastSampleTimestampMs = sampleTimestampMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
invalidatePresentation(): void {
|
||||
if (!this.presentationEligible) {
|
||||
return;
|
||||
}
|
||||
if (this.presentationEpoch < Number.MAX_SAFE_INTEGER) {
|
||||
this.presentationEpoch += 1;
|
||||
}
|
||||
this.presentationEligible = false;
|
||||
this.baselinePending = true;
|
||||
this.sequence = 0;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.connectionId = null;
|
||||
this.routeRevision = null;
|
||||
this.presentationEpoch = 0;
|
||||
this.presentationEligible = false;
|
||||
this.baselinePending = true;
|
||||
this.sequence = 0;
|
||||
this.lastSentAtMs = null;
|
||||
this.lastSampleTimestampMs = null;
|
||||
@@ -286,7 +333,8 @@ function sameViewerQualityEvidenceIdentity(
|
||||
return (
|
||||
previous.viewerPeerId === next.viewerPeerId &&
|
||||
qualityEvidenceUpstreamMatches(previous, next.upstream) &&
|
||||
previous.guard.connectionId === next.guard.connectionId
|
||||
previous.guard.connectionId === next.guard.connectionId &&
|
||||
previous.guard.presentationEpoch === next.guard.presentationEpoch
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+152
-15
@@ -63,6 +63,7 @@ import {
|
||||
deriveViewerPresentation,
|
||||
reduceViewerPresentation,
|
||||
viewerFailureFromServerCode,
|
||||
type ViewerPresentationAction,
|
||||
type ViewerRouteKind,
|
||||
type ViewerStage,
|
||||
} from "../media/viewer-presentation";
|
||||
@@ -155,7 +156,7 @@ function StageOverlayIcon({ stage }: { stage: ViewerStage }) {
|
||||
}
|
||||
|
||||
export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const [presentationState, dispatchPresentation] = useReducer(
|
||||
const [presentationState, dispatchPresentationState] = useReducer(
|
||||
reduceViewerPresentation,
|
||||
INITIAL_VIEWER_PRESENTATION_STATE,
|
||||
);
|
||||
@@ -234,6 +235,18 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const peerRef = useRef<ViewerPeer | null>(null);
|
||||
const viewerSfuRouteRef = useRef<ViewerSfuRoute | null>(null);
|
||||
const signalRef = useRef<SignalingClient | null>(null);
|
||||
const presentationStateRef = useRef(presentationState);
|
||||
presentationStateRef.current = presentationState;
|
||||
function dispatchPresentation(action: ViewerPresentationAction): void {
|
||||
presentationStateRef.current = reduceViewerPresentation(
|
||||
presentationStateRef.current,
|
||||
action,
|
||||
);
|
||||
dispatchPresentationState(action);
|
||||
}
|
||||
const qualityEvidenceReporterRef =
|
||||
useRef<ViewerQualityEvidenceReporter | null>(null);
|
||||
const qualityFrameProofGenerationRef = useRef<number | null>(null);
|
||||
const displayNameRef = useRef(displayName);
|
||||
const remoteMediaRef = useRef<RemoteMediaBinding | null>(null);
|
||||
const mediaGenerationRef = useRef(0);
|
||||
@@ -279,6 +292,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
videoTrackKey,
|
||||
generation: ++mediaGenerationRef.current,
|
||||
};
|
||||
invalidateQualityPresentation();
|
||||
remoteMediaRef.current = next;
|
||||
setRemoteMedia(next);
|
||||
dispatchPresentation({
|
||||
@@ -289,22 +303,62 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
|
||||
function clearRemoteMedia(): void {
|
||||
invalidateQualityPresentation();
|
||||
remoteMediaRef.current = null;
|
||||
setRemoteMedia(null);
|
||||
dispatchPresentation({ type: "media-cleared" });
|
||||
}
|
||||
|
||||
function resetCurrentFrameProof(): void {
|
||||
const binding = remoteMediaRef.current;
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
dispatchPresentation({
|
||||
type: "frame-proof-reset",
|
||||
generation: binding.generation,
|
||||
revision: binding.revision,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateQualityPresentation(): void {
|
||||
qualityEvidenceReporterRef.current?.invalidatePresentation();
|
||||
qualityFrameProofGenerationRef.current = null;
|
||||
}
|
||||
|
||||
function invalidatePresentedMedia(): void {
|
||||
invalidateQualityPresentation();
|
||||
resetCurrentFrameProof();
|
||||
}
|
||||
|
||||
function mediaBindingIsCurrent(binding: RemoteMediaBinding): boolean {
|
||||
const current = remoteMediaRef.current;
|
||||
return (
|
||||
current?.generation === binding.generation &&
|
||||
current.revision === binding.revision &&
|
||||
current.stream === binding.stream
|
||||
);
|
||||
}
|
||||
|
||||
function attemptPlayback(
|
||||
video: HTMLVideoElement,
|
||||
binding: RemoteMediaBinding,
|
||||
): void {
|
||||
void video.play().then(
|
||||
() =>
|
||||
() => {
|
||||
if (!mediaBindingIsCurrent(binding)) {
|
||||
return;
|
||||
}
|
||||
dispatchPresentation({
|
||||
type: "autoplay-cleared",
|
||||
generation: binding.generation,
|
||||
}),
|
||||
});
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!mediaBindingIsCurrent(binding)) {
|
||||
return;
|
||||
}
|
||||
invalidatePresentedMedia();
|
||||
if (isAutoplayPolicyRejection(error)) {
|
||||
dispatchPresentation({
|
||||
type: "autoplay-blocked",
|
||||
@@ -384,16 +438,19 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
let relayChildEvidenceCurrent: ViewerQualityEvidencePresentation | null =
|
||||
null;
|
||||
let relayChildEvidenceTimer: number | null = null;
|
||||
let sfuTransportConnected = false;
|
||||
|
||||
let pageSuspended = document.visibilityState !== "visible";
|
||||
const syncDecodedFrameStallPause = (): void => {
|
||||
decodedFrameStall.setPaused(currentHostPaused || pageSuspended);
|
||||
};
|
||||
const suspendForPageLifecycle = (): void => {
|
||||
invalidatePresentedMedia();
|
||||
pageSuspended = true;
|
||||
syncDecodedFrameStallPause();
|
||||
};
|
||||
const recoverFromPageLifecycle = (): void => {
|
||||
invalidatePresentedMedia();
|
||||
pageSuspended = document.visibilityState !== "visible";
|
||||
syncDecodedFrameStallPause();
|
||||
if (pageSuspended) return;
|
||||
@@ -485,6 +542,50 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const qualityEvidenceReporter = new ViewerQualityEvidenceReporter(
|
||||
(message) => active && signal.send(message),
|
||||
);
|
||||
qualityEvidenceReporterRef.current = qualityEvidenceReporter;
|
||||
function qualityPresentationEligible(
|
||||
connectionConnected: boolean,
|
||||
): boolean {
|
||||
const state = presentationStateRef.current;
|
||||
const binding = remoteMediaRef.current;
|
||||
const video = videoRef.current;
|
||||
return Boolean(
|
||||
connectionConnected &&
|
||||
!pageSuspended &&
|
||||
document.visibilityState === "visible" &&
|
||||
!currentHostPaused &&
|
||||
currentRouteAssignment !== null &&
|
||||
currentRouteAssignment.upstream.kind !== "none" &&
|
||||
currentRouteConnectionId !== null &&
|
||||
binding &&
|
||||
binding.revision === currentRouteRevision &&
|
||||
qualityFrameProofGenerationRef.current === binding.generation &&
|
||||
state.routeStatus?.state !== "failed" &&
|
||||
state.failure !== "ROUTE_EXHAUSTED" &&
|
||||
video &&
|
||||
!video.paused &&
|
||||
!video.ended,
|
||||
);
|
||||
}
|
||||
function offerPeerQualityEvidence(
|
||||
snapshot: PeerSnapshot,
|
||||
peer: ViewerPeer,
|
||||
): void {
|
||||
const connectionHealthy =
|
||||
snapshot.connectionState === "connected" && !peer.isRecovering();
|
||||
const qualityEligible =
|
||||
qualityPresentationEligible(connectionHealthy);
|
||||
if (!connectionHealthy) {
|
||||
invalidatePresentedMedia();
|
||||
} else if (!qualityEligible) {
|
||||
invalidateQualityPresentation();
|
||||
}
|
||||
qualityEvidenceReporter.offer(
|
||||
snapshot,
|
||||
currentRouteRevision,
|
||||
qualityEligible,
|
||||
);
|
||||
}
|
||||
function activateRouteIdentity(
|
||||
revision: number,
|
||||
assignment: ParticipantRouteAssignment,
|
||||
@@ -799,6 +900,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
return;
|
||||
}
|
||||
currentAssignment = { parentPeerId: null, childPeerIds: [] };
|
||||
sfuTransportConnected = false;
|
||||
setSfuUpstream(null);
|
||||
clearPeerState();
|
||||
viewerRelay?.setChildren([]);
|
||||
@@ -838,6 +940,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
currentRouteConnectionId,
|
||||
metrics,
|
||||
revision,
|
||||
qualityPresentationEligible(sfuTransportConnected),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -848,6 +951,13 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
},
|
||||
onSfuState: (state, revision) => {
|
||||
if (active && viewerSfuRoute === route) {
|
||||
const connected = state === "connected";
|
||||
if (sfuTransportConnected !== connected) {
|
||||
sfuTransportConnected = connected;
|
||||
if (!connected) {
|
||||
invalidatePresentedMedia();
|
||||
}
|
||||
}
|
||||
setSfuUpstream((current) =>
|
||||
current ? { ...current, connectionState: state } : null,
|
||||
);
|
||||
@@ -862,6 +972,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
if (!active || viewerSfuRoute !== route || available) {
|
||||
return;
|
||||
}
|
||||
invalidatePresentedMedia();
|
||||
setSfuUpstream((current) =>
|
||||
current ? { ...current, connectionState: "reconnecting" } : current,
|
||||
);
|
||||
@@ -889,6 +1000,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const relay = ensureViewerRelay();
|
||||
relay?.setStream(nextStream);
|
||||
bindRemoteStream(nextStream, revision);
|
||||
if (initialVideoStream) {
|
||||
sfuTransportConnected = true;
|
||||
}
|
||||
setSfuUpstream(
|
||||
(current) =>
|
||||
current ?? { connectionState: "connected", metrics: null },
|
||||
@@ -913,6 +1027,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
const route = viewerSfuRoute;
|
||||
discardPendingPeer();
|
||||
viewerSfuRoute = null;
|
||||
sfuTransportConnected = false;
|
||||
invalidatePresentedMedia();
|
||||
if (viewerSfuRouteRef.current === route) {
|
||||
viewerSfuRouteRef.current = null;
|
||||
}
|
||||
@@ -1018,7 +1134,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
snapshot.metrics.intervalFramesDecoded,
|
||||
snapshot.connectionId,
|
||||
);
|
||||
qualityEvidenceReporter.offer(snapshot, currentRouteRevision);
|
||||
offerPeerQualityEvidence(snapshot, peer);
|
||||
setPeerSnapshot(snapshot);
|
||||
dispatchPresentation({
|
||||
type: "connection",
|
||||
@@ -1104,7 +1220,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
snapshot.metrics.intervalFramesDecoded,
|
||||
snapshot.connectionId,
|
||||
);
|
||||
qualityEvidenceReporter.offer(snapshot, currentRouteRevision);
|
||||
offerPeerQualityEvidence(snapshot, peer);
|
||||
if (
|
||||
!currentHostOnline &&
|
||||
(snapshot.connectionState === "failed" ||
|
||||
@@ -1131,12 +1247,12 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
if (viewerSfuRoute) {
|
||||
return viewerSfuRoute.reportPeerFailure(parentPeerId, connectionId);
|
||||
}
|
||||
invalidateQualityPresentation();
|
||||
dispatchPresentation({
|
||||
type: "failure",
|
||||
failure: "ROUTE_EXHAUSTED",
|
||||
revision: currentRouteRevision,
|
||||
});
|
||||
setFrameProofEpoch((current) => current + 1);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
@@ -1209,6 +1325,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
currentHostOnline = message.hostOnline;
|
||||
setHostOnline(message.hostOnline);
|
||||
const sharingPaused = message.hostPaused ?? false;
|
||||
if (currentHostPaused !== sharingPaused && sharingPaused) {
|
||||
invalidatePresentedMedia();
|
||||
}
|
||||
currentHostPaused = sharingPaused;
|
||||
syncDecodedFrameStallPause();
|
||||
dispatchPresentation({
|
||||
@@ -1342,14 +1461,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "route-status") {
|
||||
if (message.state === "failed") {
|
||||
invalidateQualityPresentation();
|
||||
}
|
||||
dispatchPresentation({
|
||||
type: "route-status",
|
||||
revision: message.revision,
|
||||
state: message.state,
|
||||
});
|
||||
if (message.state === "failed") {
|
||||
setFrameProofEpoch((current) => current + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "viewer-quality-evidence") {
|
||||
@@ -1452,6 +1571,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
}
|
||||
if (message.type === "host-status") {
|
||||
currentHostOnline = message.online;
|
||||
if (currentHostPaused !== message.paused && message.paused) {
|
||||
invalidatePresentedMedia();
|
||||
}
|
||||
currentHostPaused = message.paused;
|
||||
syncDecodedFrameStallPause();
|
||||
setHostOnline(message.online);
|
||||
@@ -1471,6 +1593,7 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "sharing-stopped") {
|
||||
invalidatePresentedMedia();
|
||||
currentRouteAssignment = null;
|
||||
currentRouteConnectionId = null;
|
||||
pendingRouteConnection = null;
|
||||
@@ -1586,6 +1709,9 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
window.removeEventListener("pageshow", recoverFromPageLifecycle);
|
||||
currentPeerId = null;
|
||||
qualityEvidenceReporter.reset();
|
||||
if (qualityEvidenceReporterRef.current === qualityEvidenceReporter) {
|
||||
qualityEvidenceReporterRef.current = null;
|
||||
}
|
||||
clearRelayChildEvidence();
|
||||
sfuStandbyPrewarmer?.dispose();
|
||||
signal.stop();
|
||||
@@ -1656,13 +1782,18 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
if (!video || !remoteMedia) {
|
||||
return;
|
||||
}
|
||||
return observeCompositedVideoFrame(video, remoteMedia.stream, () =>
|
||||
const binding = remoteMedia;
|
||||
return observeCompositedVideoFrame(video, binding.stream, () => {
|
||||
if (!mediaBindingIsCurrent(binding)) {
|
||||
return;
|
||||
}
|
||||
qualityFrameProofGenerationRef.current = binding.generation;
|
||||
dispatchPresentation({
|
||||
type: "frame-presented",
|
||||
generation: remoteMedia.generation,
|
||||
revision: remoteMedia.revision,
|
||||
}),
|
||||
);
|
||||
generation: binding.generation,
|
||||
revision: binding.revision,
|
||||
});
|
||||
});
|
||||
}, [frameProofEpoch, presentationState.connection, remoteMedia]);
|
||||
|
||||
function retryConnection(): void {
|
||||
@@ -1921,10 +2052,14 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
autoPlay
|
||||
controls={
|
||||
presentation.overlay === "none" ||
|
||||
presentation.stage === "needs-play"
|
||||
presentation.stage === "needs-play" ||
|
||||
(presentation.stage === "receiving" &&
|
||||
presentation.hasRetainedFrame)
|
||||
}
|
||||
playsInline
|
||||
onPlay={() => {
|
||||
invalidateQualityPresentation();
|
||||
setFrameProofEpoch((current) => current + 1);
|
||||
const binding = remoteMediaRef.current;
|
||||
if (binding) {
|
||||
dispatchPresentation({
|
||||
@@ -1933,6 +2068,8 @@ export function ViewerPage({ roomId, viewerGrant }: ViewerPageProps) {
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPause={invalidateQualityPresentation}
|
||||
onEnded={invalidateQualityPresentation}
|
||||
/>
|
||||
{presentation.overlay === "blocking" && (
|
||||
<div className="stage-placeholder" role="status">
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface ConnectionMetrics {
|
||||
intervalFramesDropped: number | null;
|
||||
intervalFreezeCount: number | null;
|
||||
intervalFreezeDurationMs: number | null;
|
||||
intervalPauseCount: number | null;
|
||||
intervalPauseDurationMs: number | null;
|
||||
intervalRetransmittedPackets: number | null;
|
||||
intervalRetransmittedBytes: number | null;
|
||||
codec: string | null;
|
||||
@@ -125,6 +127,8 @@ export const EMPTY_METRICS: ConnectionMetrics = {
|
||||
intervalFramesDropped: null,
|
||||
intervalFreezeCount: null,
|
||||
intervalFreezeDurationMs: null,
|
||||
intervalPauseCount: null,
|
||||
intervalPauseDurationMs: null,
|
||||
intervalRetransmittedPackets: null,
|
||||
intervalRetransmittedBytes: null,
|
||||
codec: null,
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface StatsAccumulator {
|
||||
previousFramesDropped: number | null;
|
||||
previousFreezeCount: number | null;
|
||||
previousTotalFreezesDuration: number | null;
|
||||
previousPauseCount: number | null;
|
||||
previousTotalPausesDuration: number | null;
|
||||
previousRetransmittedPackets: number | null;
|
||||
previousRetransmittedBytes: number | null;
|
||||
previousVideoJitterBufferDelay: number | null;
|
||||
@@ -92,6 +94,8 @@ export function createStatsAccumulator(): StatsAccumulator {
|
||||
previousFramesDropped: null,
|
||||
previousFreezeCount: null,
|
||||
previousTotalFreezesDuration: null,
|
||||
previousPauseCount: null,
|
||||
previousTotalPausesDuration: null,
|
||||
previousRetransmittedPackets: null,
|
||||
previousRetransmittedBytes: null,
|
||||
previousVideoJitterBufferDelay: null,
|
||||
@@ -493,6 +497,8 @@ export function collectConnectionMetricsFromReport(
|
||||
const framesDropped = numberValue(media, "framesDropped");
|
||||
const freezeCount = numberValue(media, "freezeCount");
|
||||
const totalFreezesDuration = numberValue(media, "totalFreezesDuration");
|
||||
const pauseCount = numberValue(media, "pauseCount");
|
||||
const totalPausesDuration = numberValue(media, "totalPausesDuration");
|
||||
const retransmittedPackets = numberValue(
|
||||
media,
|
||||
direction === "send"
|
||||
@@ -609,6 +615,22 @@ export function collectConnectionMetricsFromReport(
|
||||
previous.previousTotalFreezesDuration,
|
||||
sampleWindowMs !== null,
|
||||
);
|
||||
const intervalPauseCount =
|
||||
direction === "receive"
|
||||
? intervalDelta(
|
||||
pauseCount,
|
||||
previous.previousPauseCount,
|
||||
sampleWindowMs !== null,
|
||||
)
|
||||
: null;
|
||||
const pauseDurationDelta =
|
||||
direction === "receive"
|
||||
? intervalDelta(
|
||||
totalPausesDuration,
|
||||
previous.previousTotalPausesDuration,
|
||||
sampleWindowMs !== null,
|
||||
)
|
||||
: null;
|
||||
const intervalRetransmittedPackets = intervalDelta(
|
||||
retransmittedPackets,
|
||||
previous.previousRetransmittedPackets,
|
||||
@@ -643,6 +665,8 @@ export function collectConnectionMetricsFromReport(
|
||||
previous.previousFramesDropped = framesDropped;
|
||||
previous.previousFreezeCount = freezeCount;
|
||||
previous.previousTotalFreezesDuration = totalFreezesDuration;
|
||||
previous.previousPauseCount = pauseCount;
|
||||
previous.previousTotalPausesDuration = totalPausesDuration;
|
||||
previous.previousRetransmittedPackets = retransmittedPackets;
|
||||
previous.previousRetransmittedBytes = retransmittedBytes;
|
||||
previous.previousVideoJitterBufferDelay = videoJitterBufferDelay;
|
||||
@@ -840,6 +864,9 @@ export function collectConnectionMetricsFromReport(
|
||||
intervalFreezeCount,
|
||||
intervalFreezeDurationMs:
|
||||
freezeDurationDelta === null ? null : freezeDurationDelta * 1_000,
|
||||
intervalPauseCount,
|
||||
intervalPauseDurationMs:
|
||||
pauseDurationDelta === null ? null : pauseDurationDelta * 1_000,
|
||||
intervalRetransmittedPackets,
|
||||
intervalRetransmittedBytes,
|
||||
codec: codecEvidence.codec,
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type CandidateReservation,
|
||||
type CandidateTuple,
|
||||
type OperationSnapshot,
|
||||
type RouteQualityEvidenceInput,
|
||||
type RouteQualityEvidenceResult,
|
||||
type RouteSnapshot,
|
||||
} from "./room-route-controller.js";
|
||||
import type { RoomStore } from "./room-store.js";
|
||||
@@ -263,6 +265,16 @@ export class HybridMediaRouter {
|
||||
);
|
||||
}
|
||||
|
||||
observeQualityEvidence(
|
||||
input: RouteQualityEvidenceInput & { roomId: string },
|
||||
): RouteQualityEvidenceResult {
|
||||
const { roomId, ...evidence } = input;
|
||||
return (
|
||||
this.rooms.get(roomId)?.controller?.observeQualityEvidence(evidence) ??
|
||||
"rejected"
|
||||
);
|
||||
}
|
||||
|
||||
isActivePeerParentOf(
|
||||
roomId: string,
|
||||
parentPeerId: string,
|
||||
|
||||
@@ -3,10 +3,12 @@ import { debuglog } from "node:util";
|
||||
|
||||
import {
|
||||
MAX_MEDIA_ROUTE_REVISION,
|
||||
VIEWER_QUALITY_EVIDENCE_EXPIRY_MS,
|
||||
type RouteDemandReason,
|
||||
type RouteDiagnosticFinalRoute,
|
||||
type RouteDiagnosticRejectionBucket,
|
||||
type RouteDiagnosticSnapshot,
|
||||
type ViewerQualityEvidenceMetrics,
|
||||
} from "../shared/protocol.js";
|
||||
import { assertEndpointMediaCopyCapacity } from "../shared/media-copy-accounting.js";
|
||||
|
||||
@@ -221,6 +223,46 @@ interface RouteTimingRecord {
|
||||
rejectionBucket: RouteDiagnosticRejectionBucket;
|
||||
}
|
||||
|
||||
interface RouteQualityObservation {
|
||||
childSessionId: string;
|
||||
upstreamKind: "peer" | "sfu";
|
||||
upstreamPeerId: string | null;
|
||||
connectionId: string;
|
||||
presentationEpoch: number;
|
||||
eligibleWindows: number;
|
||||
eligibleDurationMs: number;
|
||||
freezeWindows: number;
|
||||
freezeCount: number;
|
||||
freezeDurationMs: number;
|
||||
pauseCount: number;
|
||||
pauseDurationMs: number;
|
||||
lastAcceptedAtMs: number;
|
||||
}
|
||||
|
||||
export interface RouteQualityEvidenceInput {
|
||||
childPeerId: string;
|
||||
childSessionId: string;
|
||||
routeRevision: number;
|
||||
connectionId: string;
|
||||
upstream: { kind: "peer"; peerId: string } | { kind: "sfu" };
|
||||
presentationEpoch: number;
|
||||
windowMs: number;
|
||||
metrics: Pick<
|
||||
ViewerQualityEvidenceMetrics,
|
||||
| "framesDecodedDelta"
|
||||
| "freezeCountDelta"
|
||||
| "freezeDurationMsDelta"
|
||||
| "pauseCountDelta"
|
||||
| "pauseDurationMsDelta"
|
||||
>;
|
||||
acceptedAtMs: number;
|
||||
}
|
||||
|
||||
export type RouteQualityEvidenceResult =
|
||||
| "rejected"
|
||||
| "accepted"
|
||||
| "observed";
|
||||
|
||||
export class RoomRouteController<Resource = unknown> {
|
||||
private readonly participants = new Map<string, Participant>();
|
||||
private readonly upstreamByViewer = new Map<string, CommittedEdge<Resource>>();
|
||||
@@ -232,6 +274,11 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private nextJoinOrder = 0;
|
||||
private paused = false;
|
||||
private readonly routeTimings = new Map<string, RouteTimingRecord>();
|
||||
private readonly qualityObservations = new Map<
|
||||
string,
|
||||
RouteQualityObservation
|
||||
>();
|
||||
private readonly qualityBaselinesPending = new Set<string>();
|
||||
private readonly directContinuations = new Map<string, DirectContinuation>();
|
||||
private sfuBootstrapIntent?: SfuBootstrapIntent;
|
||||
private sfuBootstrapExhaustedAtFactVersion?: number;
|
||||
@@ -311,6 +358,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
: "waiting"
|
||||
: finalRoute,
|
||||
rejectionBucket: record?.rejectionBucket ?? "none",
|
||||
quality: this.diagnosticQuality(viewer.peerId, edge, nowMs),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -348,6 +396,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
let changed = false;
|
||||
const previousSessionId = current.sessionId;
|
||||
if (previousSessionId !== input.sessionId) {
|
||||
this.clearQualityForParticipant(input.peerId);
|
||||
const revisionBefore = this.revision;
|
||||
if (this.operationUsesParticipantSession(input.peerId)) {
|
||||
released.push(...this.abortOperation(nowMs, "stale"));
|
||||
@@ -413,6 +462,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const participant = this.participants.get(peerId);
|
||||
if (!participant || participant.sessionId !== sessionId) return false;
|
||||
participant.sessionId = null;
|
||||
this.clearQualityForParticipant(peerId);
|
||||
this.directContinuations.delete(peerId);
|
||||
this.clearSfuBootstrapForDemand(peerId);
|
||||
this.touchFacts();
|
||||
@@ -429,6 +479,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
if (participant.departureConfirmed && participant.sessionId === null &&
|
||||
participant.effectiveDownstreamCapacity === 0) return true;
|
||||
participant.sessionId = null;
|
||||
this.clearQualityForParticipant(peerId);
|
||||
participant.departureConfirmed = true;
|
||||
participant.effectiveDownstreamCapacity = 0;
|
||||
this.directContinuations.delete(peerId);
|
||||
@@ -521,6 +572,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
return false;
|
||||
}
|
||||
if (edge.usable) {
|
||||
this.clearQualityForParticipant(guard.childPeerId);
|
||||
edge.usable = false;
|
||||
this.directContinuations.delete(guard.childPeerId);
|
||||
child.blockedAtFactVersion = undefined;
|
||||
@@ -548,6 +600,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
publication.generation !== guard.generation ||
|
||||
publication.connectionId !== guard.connectionId) return false;
|
||||
if (publication.usable) {
|
||||
this.clearSfuQuality();
|
||||
publication.usable = false;
|
||||
this.directContinuations.clear();
|
||||
this.sfuBootstrapIntent = undefined;
|
||||
@@ -582,6 +635,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
return false;
|
||||
}
|
||||
if (input.newConnectionId === edge.connectionId) return true;
|
||||
this.clearQualityForParticipant(input.childPeerId);
|
||||
edge.connectionId = input.newConnectionId;
|
||||
const child = this.participants.get(input.childPeerId);
|
||||
if (child) {
|
||||
@@ -603,6 +657,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
edge.physicalActive = false;
|
||||
edge.usable = false;
|
||||
this.clearQualityForParticipant(guard.childPeerId);
|
||||
child.blockedAtFactVersion = undefined;
|
||||
this.revision = this.allocateRevision();
|
||||
if (this.operation) this.operation.baseRevision = this.revision;
|
||||
@@ -624,6 +679,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
publication.connectionId !== guard.connectionId) return [];
|
||||
publication.physicalActive = false;
|
||||
publication.usable = false;
|
||||
this.clearSfuQuality();
|
||||
this.revision = this.allocateRevision();
|
||||
if (this.operation) this.operation.baseRevision = this.revision;
|
||||
this.touchFacts();
|
||||
@@ -633,10 +689,126 @@ export class RoomRouteController<Resource = unknown> {
|
||||
setPaused(paused: boolean, nowMs?: number): readonly Resource[] {
|
||||
if (this.paused === paused) return [];
|
||||
this.paused = paused;
|
||||
if (paused) {
|
||||
this.qualityObservations.clear();
|
||||
this.qualityBaselinesPending.clear();
|
||||
}
|
||||
this.touchFacts();
|
||||
return paused ? this.abortOperation(nowMs, "aborted") : [];
|
||||
}
|
||||
|
||||
observeQualityEvidence(
|
||||
input: RouteQualityEvidenceInput,
|
||||
): RouteQualityEvidenceResult {
|
||||
if (
|
||||
this.paused ||
|
||||
input.routeRevision !== this.revision ||
|
||||
!Number.isSafeInteger(input.presentationEpoch) ||
|
||||
input.presentationEpoch < 0 ||
|
||||
!Number.isSafeInteger(input.windowMs) ||
|
||||
input.windowMs <= 0 ||
|
||||
!Number.isSafeInteger(input.acceptedAtMs) ||
|
||||
input.acceptedAtMs < 0
|
||||
) {
|
||||
return "rejected";
|
||||
}
|
||||
const child = this.participants.get(input.childPeerId);
|
||||
const edge = this.upstreamByViewer.get(input.childPeerId);
|
||||
if (
|
||||
!child ||
|
||||
child.sessionId !== input.childSessionId ||
|
||||
!edge ||
|
||||
!edge.usable ||
|
||||
!edge.physicalActive ||
|
||||
edge.childSessionId !== input.childSessionId ||
|
||||
edge.connectionId !== input.connectionId ||
|
||||
!this.qualityUpstreamMatches(edge, input.upstream) ||
|
||||
!this.sourceUsableForQuality(edge)
|
||||
) {
|
||||
return "rejected";
|
||||
}
|
||||
|
||||
const upstreamPeerId =
|
||||
input.upstream.kind === "peer" ? input.upstream.peerId : null;
|
||||
let observation = this.qualityObservations.get(input.childPeerId);
|
||||
const sameEdgeObservation =
|
||||
observation?.childSessionId === input.childSessionId &&
|
||||
observation.upstreamKind === input.upstream.kind &&
|
||||
observation.upstreamPeerId === upstreamPeerId &&
|
||||
observation.connectionId === input.connectionId;
|
||||
if (
|
||||
sameEdgeObservation &&
|
||||
observation &&
|
||||
input.presentationEpoch < observation.presentationEpoch
|
||||
) {
|
||||
return "rejected";
|
||||
}
|
||||
if (
|
||||
!sameEdgeObservation ||
|
||||
!observation ||
|
||||
observation.presentationEpoch !== input.presentationEpoch
|
||||
) {
|
||||
observation = {
|
||||
childSessionId: input.childSessionId,
|
||||
upstreamKind: input.upstream.kind,
|
||||
upstreamPeerId,
|
||||
connectionId: input.connectionId,
|
||||
presentationEpoch: input.presentationEpoch,
|
||||
eligibleWindows: 0,
|
||||
eligibleDurationMs: 0,
|
||||
freezeWindows: 0,
|
||||
freezeCount: 0,
|
||||
freezeDurationMs: 0,
|
||||
pauseCount: 0,
|
||||
pauseDurationMs: 0,
|
||||
lastAcceptedAtMs: input.acceptedAtMs,
|
||||
};
|
||||
this.qualityObservations.set(input.childPeerId, observation);
|
||||
}
|
||||
|
||||
const metrics = input.metrics;
|
||||
if (
|
||||
metrics.framesDecodedDelta === null ||
|
||||
metrics.framesDecodedDelta <= 0 ||
|
||||
metrics.freezeCountDelta === null ||
|
||||
metrics.freezeDurationMsDelta === null ||
|
||||
metrics.pauseCountDelta === null ||
|
||||
metrics.pauseDurationMsDelta === null
|
||||
) {
|
||||
return "accepted";
|
||||
}
|
||||
observation.lastAcceptedAtMs = input.acceptedAtMs;
|
||||
if (this.qualityBaselinesPending.delete(input.childPeerId)) {
|
||||
return "accepted";
|
||||
}
|
||||
|
||||
observation.eligibleWindows = safeAdd(observation.eligibleWindows, 1);
|
||||
observation.eligibleDurationMs = safeAdd(
|
||||
observation.eligibleDurationMs,
|
||||
input.windowMs,
|
||||
);
|
||||
if (metrics.freezeCountDelta > 0) {
|
||||
observation.freezeWindows = safeAdd(observation.freezeWindows, 1);
|
||||
}
|
||||
observation.freezeCount = safeAdd(
|
||||
observation.freezeCount,
|
||||
metrics.freezeCountDelta,
|
||||
);
|
||||
observation.freezeDurationMs = safeAdd(
|
||||
observation.freezeDurationMs,
|
||||
metrics.freezeDurationMsDelta,
|
||||
);
|
||||
observation.pauseCount = safeAdd(
|
||||
observation.pauseCount,
|
||||
metrics.pauseCountDelta,
|
||||
);
|
||||
observation.pauseDurationMs = safeAdd(
|
||||
observation.pauseDurationMs,
|
||||
metrics.pauseDurationMsDelta,
|
||||
);
|
||||
return "observed";
|
||||
}
|
||||
|
||||
reconcile(nowMs: number): ReconcileResult<Resource> {
|
||||
const released: Resource[] = [];
|
||||
const validation = this.validateOrAdvance(nowMs);
|
||||
@@ -1164,6 +1336,8 @@ export class RoomRouteController<Resource = unknown> {
|
||||
for (const resource of this.committedResources()) resources.add(resource);
|
||||
this.operation = undefined;
|
||||
this.upstreamByViewer.clear();
|
||||
this.qualityObservations.clear();
|
||||
this.qualityBaselinesPending.clear();
|
||||
this.hostPublication = null;
|
||||
this.participants.clear();
|
||||
this.routeTimings.clear();
|
||||
@@ -1373,6 +1547,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
private commitAttempt(operation: ChildOperation<Resource>, attempt: Attempt<Resource>): Resource[] {
|
||||
const beforeResources = new Set(this.committedResources());
|
||||
const old = this.upstreamByViewer.get(operation.childPeerId);
|
||||
this.clearQualityForParticipant(operation.childPeerId);
|
||||
|
||||
if (attempt.tuple.kind === "sfu" && attempt.tuple.publication === "replace") {
|
||||
this.removePublicationGeneration(this.hostPublication!.generation);
|
||||
@@ -2017,6 +2192,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
const edge = this.upstreamByViewer.get(participant.peerId);
|
||||
if (edge && edge.transport !== "direct" && edge.physicalActive) released.push(edge.resource);
|
||||
this.upstreamByViewer.delete(participant.peerId);
|
||||
this.clearQualityForParticipant(participant.peerId);
|
||||
this.participants.delete(participant.peerId);
|
||||
this.routeTimings.delete(participant.peerId);
|
||||
this.directContinuations.delete(participant.peerId);
|
||||
@@ -2027,6 +2203,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
if (!this.hasSfuSubscribers() && this.hostPublication) {
|
||||
this.clearSfuQuality();
|
||||
if (this.hostPublication.physicalActive) released.push(this.hostPublication.resource);
|
||||
this.hostPublication = null;
|
||||
}
|
||||
@@ -2408,6 +2585,99 @@ export class RoomRouteController<Resource = unknown> {
|
||||
: { kind: "viewer", ordinal };
|
||||
}
|
||||
|
||||
private diagnosticQuality(
|
||||
childPeerId: string,
|
||||
edge: CommittedEdge<Resource> | undefined,
|
||||
nowMs: number,
|
||||
): RouteDiagnosticSnapshot["children"][number]["quality"] {
|
||||
const observation = this.qualityObservations.get(childPeerId);
|
||||
const participant = this.participants.get(childPeerId);
|
||||
if (
|
||||
this.paused ||
|
||||
!observation ||
|
||||
observation.eligibleWindows === 0 ||
|
||||
nowMs >=
|
||||
observation.lastAcceptedAtMs + VIEWER_QUALITY_EVIDENCE_EXPIRY_MS ||
|
||||
!participant?.sessionId ||
|
||||
participant.sessionId !== observation.childSessionId ||
|
||||
!edge ||
|
||||
!edge.usable ||
|
||||
!edge.physicalActive ||
|
||||
edge.connectionId !== observation.connectionId ||
|
||||
!this.qualityObservationMatchesEdge(observation, edge) ||
|
||||
!this.sourceUsableForQuality(edge)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
eligibleWindows: observation.eligibleWindows,
|
||||
eligibleDurationMs: observation.eligibleDurationMs,
|
||||
freezeWindows: observation.freezeWindows,
|
||||
freezeCount: observation.freezeCount,
|
||||
freezeDurationMs: observation.freezeDurationMs,
|
||||
pauseCount: observation.pauseCount,
|
||||
pauseDurationMs: observation.pauseDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
private qualityUpstreamMatches(
|
||||
edge: CommittedEdge<Resource>,
|
||||
upstream: RouteQualityEvidenceInput["upstream"],
|
||||
): boolean {
|
||||
return edge.kind === "peer"
|
||||
? upstream.kind === "peer" && upstream.peerId === edge.parentPeerId
|
||||
: upstream.kind === "sfu";
|
||||
}
|
||||
|
||||
private qualityObservationMatchesEdge(
|
||||
observation: RouteQualityObservation,
|
||||
edge: CommittedEdge<Resource>,
|
||||
): boolean {
|
||||
return edge.kind === "peer"
|
||||
? observation.upstreamKind === "peer" &&
|
||||
observation.upstreamPeerId === edge.parentPeerId
|
||||
: observation.upstreamKind === "sfu" &&
|
||||
observation.upstreamPeerId === null;
|
||||
}
|
||||
|
||||
private sourceUsableForQuality(edge: CommittedEdge<Resource>): boolean {
|
||||
if (edge.kind === "peer") {
|
||||
return (
|
||||
edge.parentSessionId ===
|
||||
this.participants.get(edge.parentPeerId)?.sessionId &&
|
||||
this.sourceUsable(edge.parentPeerId)
|
||||
);
|
||||
}
|
||||
return Boolean(
|
||||
this.hostPublication?.usable &&
|
||||
this.hostPublication.physicalActive &&
|
||||
this.hostPublication.generation === edge.publicationGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
private clearQualityForParticipant(peerId: string): void {
|
||||
const affected =
|
||||
peerId === this.options.hostPeerId
|
||||
? new Set(this.upstreamByViewer.keys())
|
||||
: this.descendantsOf(peerId).add(peerId);
|
||||
affected.forEach((childPeerId) => {
|
||||
this.qualityObservations.delete(childPeerId);
|
||||
if (this.upstreamByViewer.has(childPeerId)) {
|
||||
this.qualityBaselinesPending.add(childPeerId);
|
||||
} else {
|
||||
this.qualityBaselinesPending.delete(childPeerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearSfuQuality(): void {
|
||||
for (const [childPeerId, edge] of this.upstreamByViewer) {
|
||||
if (edge.kind === "sfu") {
|
||||
this.clearQualityForParticipant(childPeerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private recordDemand(
|
||||
childPeerId: string,
|
||||
nowMs: number,
|
||||
@@ -2550,6 +2820,7 @@ export class RoomRouteController<Resource = unknown> {
|
||||
for (const [viewerPeerId, edge] of this.upstreamByViewer) {
|
||||
if (edge.kind !== "sfu" || edge.publicationGeneration !== generation) continue;
|
||||
if (edge.physicalActive) released.push(edge.resource);
|
||||
this.clearQualityForParticipant(viewerPeerId);
|
||||
this.upstreamByViewer.delete(viewerPeerId);
|
||||
}
|
||||
if (this.hostPublication?.generation === generation) {
|
||||
@@ -2658,6 +2929,16 @@ function elapsedMs(startedAtMs: number, endedAtMs: number): number | null {
|
||||
return Number.isSafeInteger(elapsed) && elapsed >= 0 ? elapsed : null;
|
||||
}
|
||||
|
||||
function safeAdd(total: number, delta: number): number {
|
||||
const rounded = Math.round(delta);
|
||||
if (!Number.isSafeInteger(rounded) || rounded < 0) {
|
||||
return total;
|
||||
}
|
||||
return rounded > Number.MAX_SAFE_INTEGER - total
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: total + rounded;
|
||||
}
|
||||
|
||||
function tupleKey(tuple: CandidateTuple): string {
|
||||
return tuple.kind === "peer" ? `peer:${tuple.parentPeerId}` : `sfu:${tuple.publication}`;
|
||||
}
|
||||
|
||||
+55
-25
@@ -70,13 +70,11 @@ interface SocketState {
|
||||
|
||||
interface ViewerQualityEvidenceGate {
|
||||
viewerSessionId: string;
|
||||
recipientSessionId: string;
|
||||
upstreamKind: "peer" | "sfu";
|
||||
upstreamPeerId: string | null;
|
||||
connectionId: string;
|
||||
routeRevision: number;
|
||||
presentationEpoch: number;
|
||||
sequence: number;
|
||||
acceptedAtMs: number;
|
||||
}
|
||||
|
||||
interface ViewerMediaReadyState {
|
||||
@@ -119,6 +117,7 @@ export class SignalingServer {
|
||||
string,
|
||||
ViewerQualityEvidenceGate
|
||||
>();
|
||||
private readonly viewerQualityEvidenceAttemptAtMs = new Map<string, number>();
|
||||
private readonly viewerMediaReadyByRoom = new Map<
|
||||
string,
|
||||
Map<string, ViewerMediaReadyState>
|
||||
@@ -252,6 +251,7 @@ export class SignalingServer {
|
||||
routeCloseError = error;
|
||||
}
|
||||
this.viewerQualityEvidenceGates.clear();
|
||||
this.viewerQualityEvidenceAttemptAtMs.clear();
|
||||
this.viewerMediaReadyByRoom.clear();
|
||||
this.shareGenerationsByRoom.clear();
|
||||
this.pausedShareGenerationsByRoom.clear();
|
||||
@@ -608,9 +608,12 @@ export class SignalingServer {
|
||||
viewerPresence: message.viewerPresence === true,
|
||||
};
|
||||
if (participant.role === "viewer") {
|
||||
this.viewerQualityEvidenceGates.delete(
|
||||
viewerConnectionKey(participant.roomId, participant.peerId),
|
||||
const viewerKey = viewerConnectionKey(
|
||||
participant.roomId,
|
||||
participant.peerId,
|
||||
);
|
||||
this.viewerQualityEvidenceGates.delete(viewerKey);
|
||||
this.viewerQualityEvidenceAttemptAtMs.delete(viewerKey);
|
||||
}
|
||||
this.clearViewerGrace(participant.roomId, participant.peerId);
|
||||
const routeParticipant = {
|
||||
@@ -1139,9 +1142,19 @@ export class SignalingServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = this.connectionIdsByViewer.get(
|
||||
viewerConnectionKey(source.roomId, source.peerId),
|
||||
);
|
||||
const gateKey = viewerConnectionKey(source.roomId, source.peerId);
|
||||
const now = this.now();
|
||||
const previousAttemptAtMs =
|
||||
this.viewerQualityEvidenceAttemptAtMs.get(gateKey);
|
||||
if (
|
||||
previousAttemptAtMs !== undefined &&
|
||||
now - previousAttemptAtMs < VIEWER_QUALITY_EVIDENCE_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.viewerQualityEvidenceAttemptAtMs.set(gateKey, now);
|
||||
|
||||
const connectionId = this.connectionIdsByViewer.get(gateKey);
|
||||
if (!connectionId || message.guard.connectionId !== connectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -1179,22 +1192,19 @@ export class SignalingServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const gateKey = viewerConnectionKey(source.roomId, source.peerId);
|
||||
const previous = this.viewerQualityEvidenceGates.get(gateKey);
|
||||
const sameGeneration =
|
||||
const sameEdge =
|
||||
previous?.viewerSessionId === viewerState.sessionId &&
|
||||
previous.recipientSessionId === recipient.sessionId &&
|
||||
previous.upstreamKind === upstream.kind &&
|
||||
previous.upstreamPeerId ===
|
||||
(upstream.kind === "peer" ? upstream.peerId : null) &&
|
||||
previous.connectionId === connectionId &&
|
||||
previous.routeRevision === routeRevision;
|
||||
const now = this.now();
|
||||
previous.connectionId === connectionId;
|
||||
if (
|
||||
sameGeneration &&
|
||||
sameEdge &&
|
||||
previous &&
|
||||
(message.sequence <= previous.sequence ||
|
||||
now - previous.acceptedAtMs < VIEWER_QUALITY_EVIDENCE_INTERVAL_MS)
|
||||
(message.guard.presentationEpoch < previous.presentationEpoch ||
|
||||
(message.guard.presentationEpoch === previous.presentationEpoch &&
|
||||
message.sequence <= previous.sequence))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1209,29 +1219,42 @@ export class SignalingServer {
|
||||
guard: {
|
||||
connectionId,
|
||||
routeRevision,
|
||||
presentationEpoch: message.guard.presentationEpoch,
|
||||
},
|
||||
sequence: message.sequence,
|
||||
windowMs: message.windowMs,
|
||||
metrics: message.metrics,
|
||||
};
|
||||
const encoded = JSON.stringify(forwarded);
|
||||
if (
|
||||
Buffer.byteLength(encoded, "utf8") >
|
||||
MAX_VIEWER_QUALITY_EVIDENCE_BYTES ||
|
||||
!this.sendEncodedToSession(recipient.sessionId, encoded)
|
||||
) {
|
||||
if (Buffer.byteLength(encoded, "utf8") > MAX_VIEWER_QUALITY_EVIDENCE_BYTES) {
|
||||
return;
|
||||
}
|
||||
const qualityResult = this.isHybridMediaEnabled()
|
||||
? (this.hybridMediaRouter?.observeQualityEvidence({
|
||||
roomId: source.roomId,
|
||||
childPeerId: source.peerId,
|
||||
childSessionId: viewerState.sessionId,
|
||||
routeRevision,
|
||||
connectionId,
|
||||
upstream,
|
||||
presentationEpoch: message.guard.presentationEpoch,
|
||||
windowMs: message.windowMs,
|
||||
metrics: message.metrics,
|
||||
acceptedAtMs: now,
|
||||
}) ?? "rejected")
|
||||
: "accepted";
|
||||
if (qualityResult === "rejected") {
|
||||
return;
|
||||
}
|
||||
this.viewerQualityEvidenceGates.set(gateKey, {
|
||||
viewerSessionId: viewerState.sessionId,
|
||||
recipientSessionId: recipient.sessionId,
|
||||
upstreamKind: upstream.kind,
|
||||
upstreamPeerId: upstream.kind === "peer" ? upstream.peerId : null,
|
||||
connectionId,
|
||||
routeRevision,
|
||||
presentationEpoch: message.guard.presentationEpoch,
|
||||
sequence: message.sequence,
|
||||
acceptedAtMs: now,
|
||||
});
|
||||
this.sendEncodedToSession(recipient.sessionId, encoded);
|
||||
if (!host || host.sessionId === recipient.sessionId) {
|
||||
return;
|
||||
}
|
||||
@@ -1976,6 +1999,11 @@ export class SignalingServer {
|
||||
this.viewerQualityEvidenceGates.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of this.viewerQualityEvidenceAttemptAtMs.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.viewerQualityEvidenceAttemptAtMs.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setViewerConnectionId(
|
||||
@@ -1986,6 +2014,7 @@ export class SignalingServer {
|
||||
const key = viewerConnectionKey(roomId, viewerPeerId);
|
||||
if (this.connectionIdsByViewer.get(key) !== connectionId) {
|
||||
this.viewerQualityEvidenceGates.delete(key);
|
||||
this.viewerQualityEvidenceAttemptAtMs.delete(key);
|
||||
}
|
||||
this.connectionIdsByViewer.set(key, connectionId);
|
||||
}
|
||||
@@ -1997,6 +2026,7 @@ export class SignalingServer {
|
||||
const key = viewerConnectionKey(roomId, viewerPeerId);
|
||||
this.connectionIdsByViewer.delete(key);
|
||||
this.viewerQualityEvidenceGates.delete(key);
|
||||
this.viewerQualityEvidenceAttemptAtMs.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-21
@@ -6,7 +6,7 @@ import { isCanonicalVideoCodecEvidence } from "./video-codec-evidence.js";
|
||||
export const MAX_VIEWERS_PER_ROOM_LIMIT = 20;
|
||||
export const MAX_PARTICIPANTS_PER_ROOM_LIMIT = MAX_VIEWERS_PER_ROOM_LIMIT + 1;
|
||||
export const MAX_SIGNAL_BYTES = 64 * 1024;
|
||||
export const SIGNALING_PROTOCOL = "screener-v12";
|
||||
export const SIGNALING_PROTOCOL = "screener-v13";
|
||||
export const SIGNAL_CLOSE_CODES = {
|
||||
serviceRestart: 1012,
|
||||
sessionReplaced: 4001,
|
||||
@@ -397,6 +397,22 @@ const routeDiagnosticParentSchema = z.discriminatedUnion("kind", [
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
const routeDiagnosticQualityValueSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER);
|
||||
const routeDiagnosticQualitySchema = z
|
||||
.object({
|
||||
eligibleWindows: routeDiagnosticQualityValueSchema,
|
||||
eligibleDurationMs: routeDiagnosticQualityValueSchema,
|
||||
freezeWindows: routeDiagnosticQualityValueSchema,
|
||||
freezeCount: routeDiagnosticQualityValueSchema,
|
||||
freezeDurationMs: routeDiagnosticQualityValueSchema,
|
||||
pauseCount: routeDiagnosticQualityValueSchema,
|
||||
pauseDurationMs: routeDiagnosticQualityValueSchema,
|
||||
})
|
||||
.strict();
|
||||
const routeDiagnosticChildSchema = z
|
||||
.object({
|
||||
ordinal: routeDiagnosticOrdinalSchema,
|
||||
@@ -418,6 +434,7 @@ const routeDiagnosticChildSchema = z
|
||||
finalMs: routeDiagnosticDurationSchema,
|
||||
finalRoute: routeDiagnosticFinalRouteSchema,
|
||||
rejectionBucket: routeDiagnosticRejectionBucketSchema,
|
||||
quality: routeDiagnosticQualitySchema.nullable(),
|
||||
})
|
||||
.strict();
|
||||
const routeDiagnosticOperationSchema = z
|
||||
@@ -524,6 +541,20 @@ const nullableSignedEvidenceNumber = (absoluteMaximum: number) =>
|
||||
const nullableEvidenceInteger = (maximum: number) =>
|
||||
z.number().int().min(0).max(maximum).nullable();
|
||||
|
||||
const nullableSafeEvidenceNumber = z
|
||||
.number()
|
||||
.finite()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.nullable();
|
||||
|
||||
const nullableSafeEvidenceInteger = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.nullable();
|
||||
|
||||
export const viewerQualityEvidenceMetricsSchema = z
|
||||
.object({
|
||||
width: z.number().int().min(1).max(16_384).nullable(),
|
||||
@@ -537,8 +568,10 @@ export const viewerQualityEvidenceMetricsSchema = z
|
||||
framesDecodedDelta: nullableEvidenceInteger(10_000),
|
||||
framesDroppedDelta: nullableEvidenceInteger(10_000),
|
||||
decodeMsPerFrame: nullableEvidenceNumber(60_000),
|
||||
freezeCountDelta: nullableEvidenceInteger(10_000),
|
||||
freezeDurationMsDelta: nullableEvidenceNumber(5_000),
|
||||
freezeCountDelta: nullableSafeEvidenceInteger,
|
||||
freezeDurationMsDelta: nullableSafeEvidenceNumber,
|
||||
pauseCountDelta: nullableSafeEvidenceInteger,
|
||||
pauseDurationMsDelta: nullableSafeEvidenceNumber,
|
||||
codec: z
|
||||
.string()
|
||||
.max(64)
|
||||
@@ -587,6 +620,11 @@ const viewerQualityEvidenceGuardSchema = z
|
||||
.object({
|
||||
connectionId: opaqueIdSchema,
|
||||
routeRevision: mediaRouteRevisionSchema,
|
||||
presentationEpoch: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -596,26 +634,13 @@ const viewerQualityEvidenceWindowShape = {
|
||||
metrics: viewerQualityEvidenceMetricsSchema,
|
||||
};
|
||||
|
||||
function freezeFitsEvidenceWindow(value: {
|
||||
windowMs: number;
|
||||
metrics: ViewerQualityEvidenceMetrics;
|
||||
}): boolean {
|
||||
return (
|
||||
value.metrics.freezeDurationMsDelta === null ||
|
||||
value.metrics.freezeDurationMsDelta <= value.windowMs
|
||||
);
|
||||
}
|
||||
|
||||
export const viewerQualityEvidenceMessageSchema = z
|
||||
.object({
|
||||
type: z.literal("viewer-quality-evidence"),
|
||||
guard: viewerQualityEvidenceGuardSchema,
|
||||
...viewerQualityEvidenceWindowShape,
|
||||
})
|
||||
.strict()
|
||||
.refine(freezeFitsEvidenceWindow, {
|
||||
message: "Viewer freeze duration exceeds its evidence window",
|
||||
});
|
||||
.strict();
|
||||
|
||||
const authenticateMessageSchema = z.discriminatedUnion("role", [
|
||||
z
|
||||
@@ -937,10 +962,7 @@ export const serverMessageSchema = z.union([
|
||||
guard: viewerQualityEvidenceGuardSchema,
|
||||
...viewerQualityEvidenceWindowShape,
|
||||
})
|
||||
.strict()
|
||||
.refine(freezeFitsEvidenceWindow, {
|
||||
message: "Viewer freeze duration exceeds its evidence window",
|
||||
}),
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("host-status"),
|
||||
|
||||
Reference in new issue
Block a user