diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index f2de750..3ccb530 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -31,6 +31,10 @@ copy cap `C`: default `2`, configurable only as `1`, `2`, or `3`. - a client may advertise only `0..C` currently available copies, producing the controller's effective capacity. +That advertised capacity belongs to the connected endpoint, not one sharing +generation. Replacing the room graph retains it; Viewer departure or room +deletion clears it. + One transition-overlap reservation may temporarily raise a producer to `min(C + 1, 3)`. Commit or abort releases it. A fourth endpoint copy is never authorized. SFU ingress and subscription egress are independent server resources diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 3c21ad2..115e9b0 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -821,15 +821,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { commitViewerQualityEvidence(peerId, null); } - function clearAllViewerQualityEvidence(): void { - viewerQualityEvidenceTimersRef.current.forEach((timer) => - window.clearTimeout(timer), - ); - viewerQualityEvidenceTimersRef.current.clear(); - viewerQualityEvidenceRef.current = new Map(); - setViewerQualityEvidence(new Map()); - } - function acceptViewerQualityEvidence(evidence: ViewerQualityEvidence): void { const peer = peersRef.current.get(evidence.viewerPeerId); const directSnapshot = peer?.getSnapshot() ?? null; @@ -1395,7 +1386,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) { setViewerPasswordEnabled(message.viewerPasswordEnabled); setViewerPasswordDraft(authenticatedProfile.roomPassword ?? ""); setViewerPasswordVisible(false); - clearAllViewerQualityEvidence(); setSfuStandbyUrl( "sfuStandbyUrl" in message ? message.sfuStandbyUrl : null, ); diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index a33d824..886c86f 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -682,9 +682,9 @@ export class HostPeer { } switch (this.limitationReason) { case "bandwidth": - return "此发送连接持续按带宽估计降低分辨率或帧率"; + return "当前连接带宽受限,画质已自动降低"; case "cpu": - return "此发送连接持续因编码性能降低分辨率或帧率"; + return "编码性能受限,画质已自动降低"; case "other": return "浏览器持续报告其他画质限制"; default: diff --git a/src/server/hybrid-media-router.ts b/src/server/hybrid-media-router.ts index 5d8d2b8..e58d5c6 100644 --- a/src/server/hybrid-media-router.ts +++ b/src/server/hybrid-media-router.ts @@ -918,11 +918,22 @@ export class HybridMediaRouter { this.options.deleteConnectionId(roomId, peerId); if (room?.controller?.confirmDeparture(peerId, this.now())) { this.requestPump(roomId); + } else if (room && !room.controller && room.advertisedCapacityByViewer.size === 0) { + this.rooms.delete(roomId); } } stopRoom(roomId: string): void { + const capacities = new Map( + this.rooms.get(roomId)?.advertisedCapacityByViewer ?? [], + ); this.clearRoom(roomId); + if (capacities.size > 0) { + const room = this.room(roomId); + for (const [peerId, capacity] of capacities) { + room.advertisedCapacityByViewer.set(peerId, capacity); + } + } } deleteRoom(roomId: string): void { diff --git a/tests/connection-details.test.ts b/tests/connection-details.test.ts index 5f0ddcc..4b37e96 100644 --- a/tests/connection-details.test.ts +++ b/tests/connection-details.test.ts @@ -23,10 +23,10 @@ describe("progressive connection details", () => { expect( qualityLimitationSummary([ snapshot(null), - snapshot("此发送连接持续按带宽估计降低分辨率或帧率"), - snapshot("此发送连接持续因编码性能降低分辨率或帧率"), + snapshot("当前连接带宽受限,画质已自动降低"), + snapshot("编码性能受限,画质已自动降低"), ]), - ).toBe("此发送连接持续按带宽估计降低分辨率或帧率"); + ).toBe("当前连接带宽受限,画质已自动降低"); expect(qualityLimitationSummary([snapshot(null)])).toBeNull(); }); diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts index 8743afc..42d116b 100644 --- a/tests/host-peer-tracks.test.ts +++ b/tests/host-peer-tracks.test.ts @@ -1379,7 +1379,9 @@ describe("HostPeer source replacement", () => { await sample(); expect(updates.at(-1)?.qualityWarning).toBeNull(); await sample(); - expect(updates.at(-1)?.qualityWarning).toContain("按带宽估计"); + expect(updates.at(-1)?.qualityWarning).toBe( + "当前连接带宽受限,画质已自动降低", + ); expect(sender.setParameters).toHaveBeenCalledTimes(2); await sample(); diff --git a/tests/hybrid-media-router.test.ts b/tests/hybrid-media-router.test.ts index 31f8d0d..e0b1737 100644 --- a/tests/hybrid-media-router.test.ts +++ b/tests/hybrid-media-router.test.ts @@ -1326,6 +1326,43 @@ describe("HybridMediaRouter v9 runtime", () => { await router.close(); }); + it("preserves connected Viewer capacity across sharing generations", async () => { + const { store, sent, router } = harness(1); + const room = await store.createRoom(); + const host = connectHost(store, room); + complete(router, host); + const root = connectViewer(store, room, "retained-capacity-root"); + complete(router, root); + await vi.waitFor(() => expect(preparedFor(sent, root.sessionId)).toBeDefined()); + const initialRoot = preparedFor(sent, root.sessionId)!; + router.handleRouteReady(root, { + type: "route-ready", + revision: initialRoot.revision, + phase: "prepare", + }); + router.setViewerRelayCapacity(root, 1); + + router.stopRoom(room.roomId); + sent.set(root.sessionId, []); + complete(router, host); + await vi.waitFor(() => expect(preparedFor(sent, root.sessionId)).toBeDefined()); + const restartedRoot = preparedFor(sent, root.sessionId)!; + router.handleRouteReady(root, { + type: "route-ready", + revision: restartedRoot.revision, + phase: "prepare", + }); + + const child = connectViewer(store, room, "retained-capacity-child"); + complete(router, child); + await vi.waitFor(() => expect(preparedFor(sent, child.sessionId)).toBeDefined()); + expect(preparedFor(sent, child.sessionId)?.assignment.upstream).toEqual({ + kind: "peer", + peerId: root.peerId, + }); + await router.close(); + }); + it("emits typed exhaustion when reconciliation has no candidate", async () => { const { store, sent, router } = harness(1); const room = await store.createRoom();