From 3057d96a8b265fb2eb5c44417e0fa3f3c3353e02 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:07:45 +0800
Subject: [PATCH 1/9] feat(ui): simplify entry and connection details
---
docs/project-memory.md | 1 +
docs/status.md | 9 +-
docs/方案设计.md | 4 +-
src/client/components/AppHeader.tsx | 2 +-
.../components/ConnectionDetailsToggle.tsx | 27 +++++
src/client/components/StatusBadge.tsx | 2 +-
src/client/components/connection-details.ts | 22 ++++
src/client/pages/HostPage.tsx | 102 ++++++++++++-----
src/client/pages/ViewerPage.tsx | 78 +++++++++----
src/client/styles.css | 107 +++++++++++++-----
tests/connection-details.test.ts | 29 +++++
11 files changed, 297 insertions(+), 86 deletions(-)
create mode 100644 src/client/components/ConnectionDetailsToggle.tsx
create mode 100644 src/client/components/connection-details.ts
create mode 100644 tests/connection-details.test.ts
diff --git a/docs/project-memory.md b/docs/project-memory.md
index c08cfef..e72394d 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -33,6 +33,7 @@ Last updated: 2026-08-19
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
+- The stacked `feat/simple-entry-diagnostics` Draft puts equal share/join actions inside the idle 16:9 stage, defaults Host/Viewer technical details closed with one native checkbox, keeps actionable warnings outside it, and uses the bounded waiting/recovery/closed copy. Chrome 151 CDP checks pass at 320/375/390 CSS px without horizontal overflow; it is not deployed.
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
diff --git a/docs/status.md b/docs/status.md
index d875935..4be93d6 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -22,6 +22,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
- Peer-assisted profile state is bounded, memory-only, absent from ordinary P2P wire, and applied to current and future relay children.
+- The stacked `feat/simple-entry-diagnostics` Draft places equal share/join actions in the idle 16:9 stage, removes the duplicate weak entry, and defaults Host/Viewer technical details closed while keeping actionable warnings visible. It is not deployed.
## Verified Evidence
@@ -31,6 +32,8 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding.
- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds.
- Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both production builds, dependency audit, and repository hygiene.
+- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
+- The stacked UI branch passes type checking, 20 Vitest files/254 tests, both production builds, and the production dependency audit.
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
- Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled.
@@ -48,9 +51,9 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
## Next Milestone
-Finish ADR-0005 before UI polish: address the 2.257-second fallback, then
-validate public transports, rollback, reconnect, controls, edge counts, egress,
-and load.
+Finish ADR-0005: address the 2.257-second fallback, then validate public
+transports, rollback, reconnect, controls, edge counts, egress, and load. Keep
+the stacked entry/diagnostics Draft separate and unmerged until its parent lands.
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
resource, quality, latency, recovery, and browser/mobile-leaf matrix.
diff --git a/docs/方案设计.md b/docs/方案设计.md
index 12d1660..725d4e6 100644
--- a/docs/方案设计.md
+++ b/docs/方案设计.md
@@ -265,7 +265,7 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面
## 官方与参考资料
-访问日期均为 2026-08-18:
+访问日期为 2026-08-18 至 2026-08-19:
- [Vite Getting Started](https://vite.dev/guide/)
- [React: Build a React app from Scratch](https://react.dev/learn/build-a-react-app-from-scratch)
@@ -282,6 +282,8 @@ spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面
- [TURN REST API draft implemented by coturn](https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00)
- [Cookies: HTTP State Management Mechanism draft (RFC6265bis)](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/)
- [Node.js `crypto.createHmac()` and `crypto.timingSafeEqual()`](https://nodejs.org/api/crypto.html)
+- [W3C WAI: Labeling Controls](https://www.w3.org/WAI/tutorials/forms/labels/)
+- [W3C WCAG 2.2: Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced)
MiroTalk BRO(AGPL-3.0)和 Screego(GPL-3.0)仅用于结构研究。Tailchat Meeting(Apache-2.0)用于捕获生命周期对照,但它采用 mediasoup/SFU,不作为本项目拓扑或依赖底座。项目许可证确定前不复制 GPL/AGPL 代码。
diff --git a/src/client/components/AppHeader.tsx b/src/client/components/AppHeader.tsx
index 8eaea5f..9d17ea5 100644
--- a/src/client/components/AppHeader.tsx
+++ b/src/client/components/AppHeader.tsx
@@ -10,7 +10,7 @@ export function AppHeader({ status }: { status: ReactNode }) {
Screener
-
{status}
+ {status && {status}
}
);
}
diff --git a/src/client/components/ConnectionDetailsToggle.tsx b/src/client/components/ConnectionDetailsToggle.tsx
new file mode 100644
index 0000000..f94d5db
--- /dev/null
+++ b/src/client/components/ConnectionDetailsToggle.tsx
@@ -0,0 +1,27 @@
+import type { ChangeEvent } from "react";
+
+interface ConnectionDetailsToggleProps {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}
+
+export function ConnectionDetailsToggle({
+ checked,
+ onChange,
+}: ConnectionDetailsToggleProps) {
+ return (
+
+ ) =>
+ onChange(event.currentTarget.checked)
+ }
+ />
+ 显示连接详情
+
+ );
+}
diff --git a/src/client/components/StatusBadge.tsx b/src/client/components/StatusBadge.tsx
index 8766e09..4459061 100644
--- a/src/client/components/StatusBadge.tsx
+++ b/src/client/components/StatusBadge.tsx
@@ -39,7 +39,7 @@ export function PeerStatusBadge({
state: RTCPeerConnectionState | "waiting";
}) {
const labels: Record = {
- waiting: { tone: "neutral", label: "等待画面" },
+ waiting: { tone: "neutral", label: "等待开始分享" },
new: { tone: "neutral", label: "准备中" },
connecting: { tone: "neutral", label: "正在连接" },
connected: { tone: "good", label: "已连接" },
diff --git a/src/client/components/connection-details.ts b/src/client/components/connection-details.ts
new file mode 100644
index 0000000..dd51696
--- /dev/null
+++ b/src/client/components/connection-details.ts
@@ -0,0 +1,22 @@
+import type { PeerSnapshot } from "../types";
+
+const QUALITY_LIMITATION_LABELS: Record = {
+ bandwidth: "带宽受限",
+ cpu: "编码性能受限",
+ other: "其他编码限制",
+};
+
+export function qualityLimitationSummary(
+ snapshots: readonly PeerSnapshot[],
+): string | null {
+ const reasons = new Set();
+ for (const snapshot of snapshots) {
+ const reason = snapshot.metrics.qualityLimitationReason;
+ if (reason && reason !== "none") {
+ reasons.add(QUALITY_LIMITATION_LABELS[reason] ?? reason);
+ }
+ }
+ return reasons.size > 0
+ ? `发送画质受限:${Array.from(reasons).join("、")}`
+ : null;
+}
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index 38d268e..e8c59b8 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -17,6 +17,8 @@ import {
type ServerMessage,
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
+import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
+import { qualityLimitationSummary } from "../components/connection-details";
import {
PathBadge,
PeerStatusBadge,
@@ -133,6 +135,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const [switchingSource, setSwitchingSource] = useState(false);
const [changingQuality, setChangingQuality] = useState(false);
const [picturePaused, setPicturePaused] = useState(false);
+ const [showConnectionDetails, setShowConnectionDetails] = useState(false);
const videoRef = useRef(null);
const streamRef = useRef(null);
@@ -153,6 +156,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
() => Array.from(peerSnapshots.values()),
[peerSnapshots],
);
+ const qualityLimitation = useMemo(
+ () => qualityLimitationSummary(viewers),
+ [viewers],
+ );
useEffect(() => {
if (videoRef.current) {
@@ -1001,7 +1008,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
return (
-
} />
+
+ ) : null
+ }
+ />
@@ -1012,13 +1025,17 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{phase === "live"
? `${viewers.length}/${maxViewers ?? "-"} 人正在观看`
: phase === "starting"
- ? "正在建立房间"
- : room
- ? "房间已就绪"
- : "尚未开始"}
+ ? "正在连接"
+ : phase === "ended" && room
+ ? "已停止分享"
+ : room
+ ? "房间已就绪"
+ : "尚未开始"}
- {forceRelay && 强制中继 }
+ {showConnectionDetails && forceRelay && (
+ 强制中继
+ )}
{(phase === "live" || phase === "starting") && (
{phase === "live" && (
@@ -1065,13 +1082,46 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
)}
-
+
{stream ? (
+ ) : phase === "idle" ||
+ phase === "ended" ||
+ phase === "error" ? (
+
+ {phase === "ended" && room && (
+
已停止分享
+ )}
+
+
void startSharing()}
+ >
+
+ 开始分享
+
+
+ 或
+
+
+
+ 加入房间
+
+
+
) : (
- {phase === "ended" ? "等待开始分享" : "未分享画面"}
)}
{(phase === "starting" || switchingSource || picturePaused) && (
@@ -1085,7 +1135,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
)}
- {details && stream && (
+ {showConnectionDetails && details && stream && (
{details.resolution}
{details.frameRate ? `${details.frameRate.toFixed(0)} fps` : "帧率未知"}
@@ -1099,12 +1149,20 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{room && signalStatus === "connected" && !relayAvailable && (
TURN 未配置,严格网络可能无法连接
)}
+ {qualityLimitation && (
+
{qualityLimitation}
+ )}
{notice && (
{notice}
)}
+
+
画质
@@ -1129,24 +1187,6 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
)}
-
- {phase !== "live" && (
-
void startSharing()}
- >
-
- {phase === "starting" ? "正在启动" : "开始分享"}
-
- )}
- {phase !== "live" && (
-
-
- 输入房间码观看
-
- )}
{room && (
@@ -1188,9 +1228,13 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
朋友 {index + 1}
-
+ {showConnectionDetails && (
+
+ )}
-
+ {showConnectionDetails && (
+
+ )}
{viewer.error && {viewer.error}
}
))}
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx
index c25be61..91da1f2 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -14,6 +14,8 @@ import {
type ServerMessage,
} from "../../shared/protocol";
import { AppHeader } from "../components/AppHeader";
+import { ConnectionDetailsToggle } from "../components/ConnectionDetailsToggle";
+import { qualityLimitationSummary } from "../components/connection-details";
import {
PathBadge,
PeerStatusBadge,
@@ -55,7 +57,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
);
const [signalStatus, setSignalStatus] =
useState("offline");
- const [statusText, setStatusText] = useState("正在进入房间");
+ const [statusText, setStatusText] = useState("正在连接");
const [hostOnline, setHostOnline] = useState(false);
const [relayAvailable, setRelayAvailable] = useState(false);
const [remoteStream, setRemoteStream] = useState(null);
@@ -63,6 +65,17 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
const [relaySnapshot, setRelaySnapshot] = useState(null);
const [playbackBlocked, setPlaybackBlocked] = useState(false);
const [muted, setMuted] = useState(false);
+ const [showConnectionDetails, setShowConnectionDetails] = useState(false);
+
+ const qualityLimitation = useMemo(
+ () =>
+ qualityLimitationSummary(
+ [peerSnapshot, relaySnapshot].filter(
+ (snapshot): snapshot is PeerSnapshot => snapshot !== null,
+ ),
+ ),
+ [peerSnapshot, relaySnapshot],
+ );
const videoRef = useRef(null);
const peerRef = useRef(null);
@@ -299,11 +312,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
if (previousParentId !== nextAssignment.parentPeerId) {
clearUpstreamState();
- setStatusText(
- nextAssignment.parentPeerId
- ? "正在切换媒体来源"
- : "等待可用的媒体来源",
- );
+ setStatusText("正在恢复连接");
}
ensureViewerRelay()?.setChild(nextAssignment.childPeerIds[0] ?? null);
}
@@ -347,7 +356,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
snapshot.connectionState === "failed"
) {
clearPeerState();
- setStatusText("等待分享者再次开始");
+ setStatusText("等待开始分享");
return;
}
setPeerSnapshot(snapshot);
@@ -357,7 +366,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
snapshot.connectionState === "failed" ||
snapshot.connectionState === "disconnected"
) {
- setStatusText("正在恢复媒体连接");
+ setStatusText("正在恢复连接");
}
}
},
@@ -416,9 +425,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
) {
clearPeerState();
}
- setStatusText(
- message.hostOnline ? "等待分享画面" : "等待分享者开始分享",
- );
+ setStatusText(message.hostOnline ? "正在连接" : "等待开始分享");
const peer = peerRef.current;
peer?.updateIceConfig(message.iceConfig);
viewerRelay?.updateIceConfig(message.iceConfig);
@@ -499,7 +506,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
const peer = ensurePeer();
if (!peer) {
- setStatusText("尚未收到可用的 ICE 配置");
+ setStatusText("正在连接");
return;
}
await peer.acceptSignal(message.fromPeerId, message.payload);
@@ -526,9 +533,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
currentHostOnline = message.online;
setHostOnline(message.online);
if (!message.online && !peerRef.current?.isConnected()) {
- setStatusText("等待分享者开始分享");
+ setStatusText("等待开始分享");
} else if (message.online && !peerRef.current?.isConnected()) {
- setStatusText("等待分享画面");
+ setStatusText("正在连接");
}
return;
}
@@ -537,20 +544,20 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
clearViewerSfuRoute();
clearPeerState();
setHostOnline(false);
- setStatusText("分享已停止,等待分享者再次开始");
+ setStatusText("等待开始分享");
return;
}
if (message.type === "room-closed") {
clearViewerSfuRoute();
clearPeerState();
- setStatusText(message.reason === "expired" ? "房间已过期" : "分享已结束");
+ setStatusText(message.reason === "expired" ? "房间已过期" : "房间已关闭");
signal.stop();
return;
}
if (message.type === "error") {
if (message.code === "PEER_NOT_FOUND" && !currentHostOnline) {
clearPeerState();
- setStatusText("等待分享者再次开始");
+ setStatusText("等待开始分享");
return;
}
if (
@@ -640,15 +647,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
function retryConnection(): void {
if (!peerRef.current?.requestRecovery()) {
- setStatusText(hostOnline ? "等待分享画面" : "等待分享者开始分享");
+ setStatusText(hostOnline ? "正在连接" : "等待开始分享");
} else {
- setStatusText("正在恢复媒体连接");
+ setStatusText("正在恢复连接");
}
}
return (
-
} />
+
+ ) : null
+ }
+ />
@@ -657,9 +670,13 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
房间 {roomId}
- {forceRelay &&
强制中继 }
+ {showConnectionDetails && forceRelay && (
+
强制中继
+ )}
-
+ {showConnectionDetails && (
+
+ )}
@@ -726,6 +743,11 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
+
+
{!relayAvailable &&
signalStatus === "connected" &&
(hostOnline || peerSnapshot) && (
@@ -736,13 +758,21 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
{peerSnapshot.error}
)}
- {peerSnapshot && (
+ {relaySnapshot?.error && (
+
+ {relaySnapshot.error}
+
+ )}
+ {qualityLimitation && (
+ {qualityLimitation}
+ )}
+ {showConnectionDetails && peerSnapshot && (
)}
- {relaySnapshot && (
+ {showConnectionDetails && relaySnapshot && (
转发数据
diff --git a/src/client/styles.css b/src/client/styles.css
index 5cc02c6..c7c3fca 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -252,6 +252,70 @@ h3 {
background: rgba(5, 7, 6, 0.72);
}
+.stage-entry {
+ gap: 13px;
+ width: 100%;
+}
+
+.stage-status {
+ color: var(--muted);
+}
+
+.entry-actions {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr);
+ width: min(520px, calc(100% - 20px));
+ gap: 8px;
+ align-items: stretch;
+}
+
+.entry-action {
+ display: inline-flex;
+ min-width: 0;
+ min-height: 44px;
+ gap: 7px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 9px;
+ border: 1px solid #326645;
+ border-radius: 6px;
+ color: var(--text);
+ background: #14271b;
+ font-size: 13px;
+ font-weight: 700;
+ text-decoration: none;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+.entry-action svg {
+ flex: 0 0 auto;
+ color: var(--green);
+}
+
+.entry-action:hover {
+ border-color: var(--green-strong);
+ background: var(--green-soft);
+}
+
+.entry-divider {
+ display: grid;
+ grid-template-rows: minmax(3px, 1fr) auto minmax(3px, 1fr);
+ gap: 3px;
+ justify-items: center;
+ align-items: center;
+ color: var(--faint);
+ font-size: 11px;
+}
+
+.entry-divider::before,
+.entry-divider::after {
+ width: 1px;
+ height: 100%;
+ background: var(--border-strong);
+ content: "";
+}
+
.capture-strip {
display: flex;
min-height: 34px;
@@ -291,28 +355,34 @@ h3 {
}
.setup-controls {
- display: grid;
- grid-template-columns: minmax(310px, 1fr) auto;
- gap: 18px;
- align-items: end;
margin-top: 22px;
}
-.join-room-link {
+.connection-details-toggle {
display: inline-flex;
- grid-column: 1 / -1;
- width: fit-content;
- gap: 7px;
+ min-height: 44px;
+ max-width: 100%;
+ gap: 9px;
align-items: center;
+ margin-top: 8px;
+ padding: 0 2px;
color: var(--muted);
font-size: 13px;
- text-decoration: none;
+ cursor: pointer;
}
-.join-room-link:hover {
+.connection-details-toggle:hover {
color: var(--text);
}
+.connection-details-toggle input {
+ width: 18px;
+ height: 18px;
+ flex: 0 0 18px;
+ margin: 0;
+ accent-color: var(--green-strong);
+}
+
.control-group {
min-width: 0;
margin: 0;
@@ -733,14 +803,6 @@ h3 {
flex-wrap: wrap;
}
- .setup-controls {
- grid-template-columns: minmax(0, 1fr) auto;
- }
-
- .start-button {
- grid-column: auto;
- }
-
.viewer-stats .stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -767,15 +829,6 @@ h3 {
border-left: 0;
}
- .setup-controls {
- grid-template-columns: 1fr;
- gap: 14px;
- }
-
- .start-button {
- grid-column: auto;
- }
-
.remote-stage {
max-height: none;
}
diff --git a/tests/connection-details.test.ts b/tests/connection-details.test.ts
new file mode 100644
index 0000000..586a443
--- /dev/null
+++ b/tests/connection-details.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { qualityLimitationSummary } from "../src/client/components/connection-details.ts";
+import { EMPTY_METRICS, type PeerSnapshot } from "../src/client/types.ts";
+
+function snapshot(reason: string | null): PeerSnapshot {
+ return {
+ peerId: "viewer-1",
+ connectionId: "connection-1",
+ connectionState: "connected",
+ iceConnectionState: "connected",
+ metrics: { ...EMPTY_METRICS, qualityLimitationReason: reason },
+ error: null,
+ };
+}
+
+describe("progressive connection details", () => {
+ it("keeps active quality limitations available outside the details panel", () => {
+ expect(
+ qualityLimitationSummary([
+ snapshot("none"),
+ snapshot("bandwidth"),
+ snapshot("cpu"),
+ snapshot("bandwidth"),
+ ]),
+ ).toBe("发送画质受限:带宽受限、编码性能受限");
+ expect(qualityLimitationSummary([snapshot(null)])).toBeNull();
+ });
+});
From 97bf1dcfc512c06163a6ae5ebd6b8c03a318fa24 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:17:41 +0800
Subject: [PATCH 2/9] fix(ui): hide TURN diagnostics by default
---
src/client/pages/HostPage.tsx | 5 ++++-
src/client/pages/ViewerPage.tsx | 3 ++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index e8c59b8..607f515 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -1146,7 +1146,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
{!details?.hasAudio && stream && (
当前来源没有可共享音频
)}
- {room && signalStatus === "connected" && !relayAvailable && (
+ {showConnectionDetails &&
+ room &&
+ signalStatus === "connected" &&
+ !relayAvailable && (
TURN 未配置,严格网络可能无法连接
)}
{qualityLimitation && (
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx
index 91da1f2..3b03c94 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -748,7 +748,8 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
onChange={setShowConnectionDetails}
/>
- {!relayAvailable &&
+ {showConnectionDetails &&
+ !relayAvailable &&
signalStatus === "connected" &&
(hostOnline || peerSnapshot) && (
TURN 未配置,严格网络可能无法连接
From abbca9ff21cdf0228c55bde53309854dc9f92d9d Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:30:14 +0800
Subject: [PATCH 3/9] feat(media): add bounded clarity controls
---
docs/方案设计.md | 12 +-
docs/需求理解.md | 8 +-
scripts/peer-assisted-benchmark.ts | 227 +++++++++++++++++++++---
src/client/components/StatsGrid.tsx | 58 ++++++
src/client/media/host-sfu-route.ts | 7 +
src/client/media/quality.ts | 200 +++++++++++++++++----
src/client/pages/HostPage.tsx | 244 +++++++++++++++++++++-----
src/client/pages/ViewerPage.tsx | 39 ++--
src/client/sfu/publisher.ts | 27 ++-
src/client/styles.css | 94 +++++++++-
src/client/types.ts | 2 +
src/client/webrtc/host-peer.ts | 114 +++++++++---
src/server/signaling.ts | 31 ++--
src/shared/protocol.ts | 38 +++-
tests/host-peer-tracks.test.ts | 115 +++++++++---
tests/peer-assisted-benchmark.test.ts | 33 +++-
tests/protocol.test.ts | 63 ++++---
tests/quality.test.ts | 98 +++++++++--
tests/server-signal.test.ts | 76 +++++---
tests/sfu-client.test.ts | 67 ++++++-
20 files changed, 1273 insertions(+), 280 deletions(-)
diff --git a/docs/方案设计.md b/docs/方案设计.md
index 12d1660..a7a4c52 100644
--- a/docs/方案设计.md
+++ b/docs/方案设计.md
@@ -133,9 +133,9 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate
| 1080p30 | 1920x1080、30 fps | 5 Mbps/peer |
| 720p30 | 1280x720、30 fps | 3 Mbps/peer |
-浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际结果。视频 track 保留 `contentHint = "motion"`;sender 尝试设置 `maxBitrate`、`maxFramerate` 与 `degradationPreference = "balanced"`。`balanced` 只允许浏览器在帧率和分辨率之间权衡,不保证清晰度优先;具体 Chromium、Firefox 与 Safari 行为仍需实测。不支持 sender 参数时只记录警告。
+浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。视频 track 保留 `contentHint = "motion"`;待合并质量控制默认使用 `degradationPreference = "maintain-resolution"`,三个推荐档位都采用清晰优先。折叠高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。
-生产反馈显示三个档位都可能被降到难以阅读的分辨率、帧率和码率。下一质量迭代先在同一高动态场景对 1、2、3 名观看者做一次 60 至 90 秒采样,并对 `maintain-resolution` 与当前 `balanced` 做 A/B;默认目标是清晰优先,高级区再提供清晰、平衡和流畅三种偏好。不得只提高 ceiling 或强制 codec。只有实际/target bitrate 长期顶住 ceiling、网络仍有余量且画质仍不足时,才增加更高的可选码率上限。
+每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。生产版本仍使用 `balanced`,用户报告的严重降级尚未完成受控分类;本改动只提供有界 clarity/balanced 控制,不得据此宣称画质提升。下一次真实质量验收仍需在同一高动态场景采样 1、2、3 名观看者和 TURN 路径。不得只提高 ceiling、强制 codec 或修改 SDP。
PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商 codec、`encoderImplementation` 和 `powerEfficientEncoder`,再依据目标机器测量决定是否优先 H.264。这样避免静态 codec 偏好意外触发软件编码。
@@ -143,7 +143,7 @@ PoC 不强制 codec,也不做 SDP munging。先通过 stats 记录实际协商
直播中切换来源仍由明确的按钮手势重新调用 `getDisplayMedia()`,不改变房间、host generation 或媒体拓扑。选源取消或失败时继续发送旧流;成功后为每个现有 sender 调用 `replaceTrack()`,健康连接不重新协商。初始 offer 预留 send-only audio transceiver,使音频可以在 `null -> track -> null` 间切换。若某个 peer 的替换或回滚失败,只重建该 peer;新流提交后才停止旧流,并以当前 stream 身份保护旧 track 的迟到 `ended` 事件。统计码率累加器在换源后重置,避免把不同 track 的计数器混算。
-直播中切换质量档位不重新打开选源器:对当前视频 track 调用 `applyConstraints()`,再更新所有现有 sender 的参数;新加入或重建的 sender 使用当前档位。失败时保留当前分享并给出状态,不以重建整个房间补偿。临时暂停只禁用当前视频 track,使远端收到黑画面;音频、房间和连接保持不变,恢复时重新启用同一 track。
+直播中切换质量不重新打开选源器:对当前视频 track 调用 `applyConstraints()`,再更新所有现有 sender 的参数;新加入、重建的 relay sender 和启用的 SFU publisher 使用同一权威设置。失败时保留当前分享并给出状态,不以重建整个房间补偿。临时暂停只禁用当前视频 track,使远端收到黑画面;音频、房间和连接保持不变,恢复时重新启用同一 track。浏览器标准 API 无法独立保证音频 stereo/bitrate 与最终协商 codec,因此当前高级区不暴露对应控件。
`frameRate` 约束和 sender 的 `maxFramerate` 都是目标或上限;浏览器仍控制实际捕获、重复帧处理、编码和拥塞降级。静态画面可能由浏览器减少实际编码数据,但标准没有保证每个实现以同一种方式节省采集或 GPU 开销。本 PoC 不添加画面差分、定时改约束或自定义动态 FPS 状态机,先用 `framesEncoded`、实际发送码率、`totalEncodeTime`、质量受限原因和主机资源占用测量真实问题。
@@ -171,7 +171,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
每两秒在本地采集一次 `getStats()`。首版不上传原始统计,也不展示候选地址。
-质量诊断必须能按顺序区分:捕获源设置、sender 实际参数、CPU 或带宽 `qualityLimitationReason`、selected candidate/TURN 路径、outbound 与 inbound 差异,以及接收端 dropped/freeze。每次成功调用 `setParameters()` 后读回并记录实际 `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 与 degradation preference;参数拒绝不能只留在控制台。首轮使用浏览器 WebRTC internals 导出和一份脱敏 run manifest,不新增服务端遥测、综合评分器或自动调档状态机。
+质量诊断必须能按顺序区分:捕获源设置、sender 实际参数、CPU 或带宽 `qualityLimitationReason`、selected candidate/TURN 路径、outbound 与 inbound 差异,以及接收端 dropped/freeze。当前有界实现显示 sender 参数的 requested/applied 读回;同一个非 `none` 原生 `qualityLimitationReason` 连续出现三个采样周期后显示单条原因提示,原因变化、缺失或恢复为 `none` 即重新计数。这只是去抖后的观察,不组合评分、不触发调档或迁移。完整跨路径分类仍由后续真实矩阵验收;首轮使用浏览器 WebRTC internals 导出和一份脱敏 run manifest,不新增服务端遥测。
分享者逐观看者显示:
@@ -253,9 +253,9 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
所有边都继续使用标准 WebRTC。每名 viewer 播放收到的 remote `MediaStream`,并把其中的 remote tracks 加入给唯一 child 的新 `RTCPeerConnection`。该路径会逐跳解码和重编码;实验必须记录每层 encode time、CPU/GPU、`qualityLimitationReason`、帧率、分辨率和延迟,不能称为 zero-copy 或 shared encode。
-Peer-assisted 信令只接受 `1080p60`、`1080p30`、`720p30` 三个 `qualityProfileId`。服务端用受房间容量限制的内存 Map 保存当前值,默认 `1080p60`,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。peer-assisted 鉴权快照携带当前值,host 鉴权后先重报本地选择再创建已分配的 child,直播中换档时通知所有在线 viewers,后加入者直接取得最新快照。普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送档位同步消息都会被拒绝。
+Peer-assisted 信令只接受一个严格对象:`resolution` 为 `720p | 1080p | 1440p`,`maxFramerate` 为 15 至 60 的整数,`maxBitrate` 为 2,000,000 至 12,000,000 的整数,`degradationPreference` 为 `maintain-resolution | balanced | maintain-framerate`,缺字段、多余字段和越界值都拒绝。服务端用受房间容量限制的内存 Map 保存完整设置,默认 1080p60、8 Mbps、清晰优先,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一完整设置;普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送同步消息都会被拒绝。启用 SFU 时 publisher 也使用这一设置,不另建一套质量协议。
-Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired profile,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;`desiredProfile` 在入队前同步更新,已排队操作执行时读取最新值,使快速连续换档保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
+Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired settings,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;desired settings 在入队前同步更新,已排队操作执行时读取最新值,使快速连续修改保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
spike 复用现有 screen stream 和浏览器 codec 协商,覆盖当前桌面 Chrome/Edge relay、按测试顺序位于链尾的当前 Android Chrome 与 iOS Safari leaf、最多八名 viewer;捕获存在时沿同一标准 WebRTC 路径带上音频。Encoded Transform/DataChannel/WebCodecs 媒体、SVC/simulcast、多树、转码、codec ladder、综合评分、移动端后台 relay 和跨模式 SFU 迁移不在本次 spike 内实现。该范围限制不能解释为拒绝产品所需的自动兜底;验收与失败即放弃条件以 ADR-0004 为准,自动路由由后续独立 ADR 定义。
diff --git a/docs/需求理解.md b/docs/需求理解.md
index 381d910..d3e7e5c 100644
--- a/docs/需求理解.md
+++ b/docs/需求理解.md
@@ -21,7 +21,7 @@
- [ ] 分享端 fanout 硬上限:分享端任何时刻最多维护两条下游媒体边,包括加入、断线、换父和恢复过程。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,必要时自动回退到部署已提供的可选 SFU;两者都不可用时才明确拒绝,不能隐藏地回退为分享端第三条连接。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
- [ ] NAT 兜底:每条 parent-child 边独立进行 ICE;生产环境必须提供 STUN、TURN/UDP 与 TURN/TCP candidates,通过优先级偏好直连和 UDP,同时允许无法使用 UDP 的网络选择 TCP,不能因为某条边的网络与其他边不同而静默失败。
-- [ ] 质量档位:提供 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档上限;分享期间可直接换档而不重新选择来源或重建健康连接。Peer-assisted 房间只允许这三个档位 ID,并把分享者当前选择同步给现有和后续 relay sender;普通 P2P 信令结构保持不变。当前 `contentHint = "motion"` 与 `balanced` 只是测量基线;目标默认策略为可读性优先,并在高级设置中提供“清晰优先 / 平衡 / 流畅优先”。任何策略都只是浏览器偏好,必须显示实际结果和限制原因。自定义动态 FPS 或画面检测只在统计证明必要后实现。
+- [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,默认采用清晰优先;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与可选 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,必须读回实际 sender 参数并显示参数拒绝、改写和持续受限原因。自定义动态 FPS、综合评分或周期调档只在统计证明必要后实现。
- [ ] 断线恢复:临时网络变化时尝试 ICE restart 和重连,并向用户显示当前连接状态。
- [ ] 可观测性:采集候选类型、RTT、丢包、抖动、实际码率、帧率、编码耗时和质量受限原因。
- [ ] 渐进式诊断:分享者和观看者默认只看到房间、画面、核心状态和常用控制;候选路径、RTT、丢包、编解码器、编码耗时等技术详情由一个明确的“显示连接详情”开关展开。隐藏诊断不能隐藏失败原因、音频缺失、持续画质受限或需要用户操作的告警。
@@ -30,8 +30,8 @@
### P1 - 重要功能
-- [ ] 质量控制扩展:保留可立即理解的推荐档位作为默认入口,同时提供一个可选的高级设置区,让分享者在安全边界内实时调整分辨率、帧率、视频码率、质量优先级和音频模式而无需重选来源或重建健康连接。实际发送/接收质量持续明显低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一固定参数组合称为“完美平衡”。
-- [ ] Peer-assisted 媒体实验:分享端最多两个 child、每名 viewer 最多一个 child;信令服务只按深度和服务端加入序号确定性地形成两条 sticky 平衡链,不加入综合评分或主动重平衡。每个 viewer 将收到的标准 remote track 通过新的 `RTCPeerConnection` 转发,因此会逐跳解码和重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前档位,不写入 SQLite;host 鉴权后先重报档位,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。该功能在 ADR-0004 的实验门槛通过并由后续 ADR 正式接受前不得成为产品路径。
+- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。音频 stereo/bitrate 在标准浏览器 API 和实际协商 codec 不能可靠保证前只保留为后续测量项,不提供伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
+- [ ] Peer-assisted 媒体实验:分享端最多两个 child、每名 viewer 最多一个 child;信令服务只按深度和服务端加入序号确定性地形成两条 sticky 平衡链,不加入综合评分或主动重平衡。每个 viewer 将收到的标准 remote track 通过新的 `RTCPeerConnection` 转发,因此会逐跳解码和重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。该功能在 ADR-0004 的实验门槛通过并由后续 ADR 正式接受前不得成为产品路径。
- [ ] 原生 shared encode 分享端:后续独立的 Electron/原生分享端阶段必须保证一份兼容编码输出扇出到最多两套独立 WebRTC packetizer,并由单独 ADR 和测量约束。它降低分享端重复编码负载,但不消除每条出边各自的上传带宽。浏览器不保证跨 `RTCPeerConnection` 复用编码器,因此当前 Web spike 不实现、也不能宣称满足 shared encode 保证。
- [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样最多维护两条下游媒体边,并应让一份兼容编码输出供两条边复用。普通浏览器 audience 继续作为 leaf 或使用当前会逐跳重编码的实验路径,不伪称 shared encode。
- [ ] 分包/分层多树实验:把编码包、对象或可独立解码层拆到多个 peer 路径以分散上传,只能在独立 spike 中验证。必须保持亚秒级,处理多父重组、排序、丢包恢复、关键帧、同步和节点退出;未通过门槛前不进入产品路由控制器。
@@ -98,7 +98,7 @@
- Given 一名分享者和一至两名观看者,When 运行直接 P2P,Then 分享端为每人建立独立媒体边且总 fanout 不超过二。
- Given peer-assisted spike 分别运行 1、3、5、8 名观看者,When 用户加入、离开或 relay 失效,Then 服务端使用同一个确定性广度优先规则形成两条 sticky 链,只为新 viewer 或失去 parent 的 subtree root 分配 parent,不主动搬动其他节点;分享端在任何瞬间都不超过两个 child 且每名 viewer 不超过一个,没有可用 parent 时明确等待或拒绝,而不是增加分享端 fanout。
- Given 一名分享者和八名观看者,When 在受控网络中运行已记录的质量档位,Then 记录每条边的拓扑深度、候选类型、码率、编码/解码负载、分享端与 relay 上行、首帧和玻璃到玻璃延迟;该条在真实矩阵完成前保持未验收。
-- Given peer-assisted host 鉴权或直播中换档,When 其选择三个受限档位之一,Then 当前和以后建立的 relay child sender 都使用最新目标档位,服务端只保留房间级内存状态并通知在线 viewers;普通 P2P 的鉴权 wire 不新增该字段,且不接受档位同步消息。
+- Given peer-assisted host 鉴权或直播中连续修改质量设置,When 服务端校验其分辨率、FPS、码率和偏好,Then 只有 host 的完整有界设置被接受,last-wins 地应用到当前和以后建立的 relay child sender 以及启用的 SFU publisher;越界、缺字段和多余字段被拒绝,普通 P2P 的鉴权 wire 不新增该字段且不接受同步消息。
- Given 标准浏览器把收到的 remote track 直接加入下游 `RTCPeerConnection`,When 检查 relay 统计和 libwebrtc 路径,Then 明确记录新的 outbound encoder、编码耗时和 CPU/GPU 限制,不把它当作 encoded forwarding 或 shared encode 成果。
- Given ADR-0004 的任一硬门槛只有在引入 Encoded Transform/DataChannel/WebCodecs 媒体、自定义拥塞控制、FEC/RTX、多树、综合评分、转码、codec ladder、浏览器 RTP 注入 hack 或放宽 host fanout 后才能满足,When 结束 bounded spike,Then 放弃并删除浏览器 relay 实验运行时代码,保留标准 P2P 和可选 SFU;后续 native shared encode sender 仍按独立 ADR 推进,但不能据此把失败的 relay 拓扑判为通过。
- Given 新观看者会使预计总上行超过安全预算,When 其请求加入,Then 系统自动降低档位、拒绝该档位或建议改用外部直播服务,而不是持续堆积发送队列。
diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts
index 7f5094e..0d64719 100644
--- a/scripts/peer-assisted-benchmark.ts
+++ b/scripts/peer-assisted-benchmark.ts
@@ -11,14 +11,15 @@ import {
type ScreenerServer,
} from "../src/server/app";
import { loadConfig } from "../src/server/config";
+import {
+ QUALITY_PROFILES,
+ QUALITY_RESOLUTIONS,
+ type QualityProfileId,
+ type QualitySettings,
+} from "../src/client/media/quality";
-const PROFILE_SETTINGS = {
- "1080p60": { width: 1920, height: 1080, frameRate: 60 },
- "1080p30": { width: 1920, height: 1080, frameRate: 30 },
- "720p30": { width: 1280, height: 720, frameRate: 30 },
-} as const;
-
-type ProfileId = keyof typeof PROFILE_SETTINGS;
+const PROFILE_SETTINGS = QUALITY_PROFILES;
+type ProfileId = QualityProfileId;
type PageRole = "host" | "viewer";
export interface BenchmarkConfig {
@@ -34,6 +35,7 @@ export interface BenchmarkConfig {
outputPath: string | null;
headless: boolean;
noSandbox: boolean;
+ qualityControlSmoke: boolean;
}
interface MediaTotals {
@@ -72,7 +74,7 @@ interface PageObservation {
authenticatedAtEpochMs: number | null;
authenticateSentAtEpochMs: number | null;
signalingConnected: boolean;
- qualityProfileId: string | null;
+ qualitySettings: QualitySettings | null;
assignment: MediaAssignmentObservation;
maxActiveOutboundMediaEdges: number;
maxAssignedChildren: number;
@@ -343,6 +345,16 @@ export function parseBenchmarkConfig(
throw new Error("BENCHMARK_PROFILE must be 1080p60, 1080p30, or 720p30");
}
const viewerCounts = parseViewerCounts(environment.BENCHMARK_VIEWERS);
+ const qualityControlSmoke = parseBoolean(
+ environment.BENCHMARK_QUALITY_SMOKE,
+ false,
+ "BENCHMARK_QUALITY_SMOKE",
+ );
+ if (qualityControlSmoke && !viewerCounts.some((count) => count >= 3)) {
+ throw new Error(
+ "BENCHMARK_QUALITY_SMOKE requires a selected viewer count of at least 3",
+ );
+ }
const recoveryText = environment.BENCHMARK_RECOVERY_VIEWERS?.trim();
const recoveryViewerCount = recoveryText ? Number(recoveryText) : null;
if (
@@ -410,6 +422,7 @@ export function parseBenchmarkConfig(
false,
"BENCHMARK_CHROME_NO_SANDBOX",
),
+ qualityControlSmoke,
};
}
@@ -551,7 +564,7 @@ export function summarizeSamples(samples: TimedSample[], viewerCount: number) {
role: page.role,
viewerIndex: page.viewerIndex,
peerId: page.peerId,
- qualityProfileId: page.qualityProfileId,
+ qualitySettings: page.qualitySettings,
parentPeerId: page.assignment.parentPeerId,
childPeerIds: page.assignment.childPeerIds,
})),
@@ -617,14 +630,18 @@ function buildRunChecks(
expected: "<= 3000 ms after signaling authentication starts",
},
{
- name: "quality-profile-propagated",
+ name: "quality-settings-propagated",
passed: summary.finalTopology.every(
- (participant) => participant.qualityProfileId === profileId,
+ (participant) =>
+ JSON.stringify(participant.qualitySettings) ===
+ JSON.stringify(PROFILE_SETTINGS[profileId]),
),
actual: summary.finalTopology.every(
- (participant) => participant.qualityProfileId === profileId,
+ (participant) =>
+ JSON.stringify(participant.qualitySettings) ===
+ JSON.stringify(PROFILE_SETTINGS[profileId]),
),
- expected: `${profileId} on every participant`,
+ expected: `${profileId} quality settings on every participant`,
},
];
}
@@ -654,7 +671,7 @@ export function buildBenchmarkInitScript(options: {
authenticatedAtEpochMs: null,
authenticateSentAtEpochMs: null,
signalingConnected: false,
- qualityProfileId: null,
+ qualitySettings: null,
assignment: { parentPeerId: null, childPeerIds: [] },
maxActiveOutboundMediaEdges: 0,
maxAssignedChildren: 0,
@@ -710,8 +727,8 @@ export function buildBenchmarkInitScript(options: {
state.role = message.role;
state.roomId = message.roomId;
state.authenticateSentAtEpochMs = Date.now();
- } else if (direction === "out" && message.type === "set-quality-profile") {
- state.qualityProfileId = message.qualityProfileId;
+ } else if (direction === "out" && message.type === "set-quality-settings") {
+ state.qualitySettings = message.qualitySettings;
}
if (direction === "in" && message.type === "authenticated") {
socket.__screenerBenchmarkSignal = true;
@@ -720,12 +737,12 @@ export function buildBenchmarkInitScript(options: {
state.signalingConnected = true;
if (message.mediaMode === "peer-assisted") {
setAssignment(message.mediaAssignment);
- state.qualityProfileId = message.qualityProfileId;
+ state.qualitySettings = message.qualitySettings;
}
} else if (direction === "in" && message.type === "media-assignment") {
setAssignment(message.mediaAssignment);
- } else if (direction === "in" && message.type === "quality-profile") {
- state.qualityProfileId = message.qualityProfileId;
+ } else if (direction === "in" && message.type === "quality-settings") {
+ state.qualitySettings = message.qualitySettings;
}
if (message.type === "signal") recordDescription(message, direction);
}
@@ -1302,6 +1319,137 @@ async function samplePages(
};
}
+function peerConnectionFingerprint(pages: PageObservation[]): string {
+ return pages
+ .map((page) =>
+ [
+ page.label,
+ ...page.connections.map(
+ (connection) =>
+ `${connection.index}:${connection.connectionId ?? "?"}`,
+ ),
+ ].join("|"),
+ )
+ .sort()
+ .join("\n");
+}
+
+async function applyQualityPreference(
+ cdp: CdpConnection,
+ hostPage: PageHandle,
+ label: "平衡" | "清晰优先",
+ signal: AbortSignal,
+): Promise {
+ const serializedLabel = JSON.stringify(label);
+ await evaluate(
+ cdp,
+ hostPage,
+ `(() => {
+ const details = document.querySelector('.advanced-quality');
+ if (!(details instanceof HTMLDetailsElement)) return false;
+ details.open = true;
+ const button = Array.from(document.querySelectorAll('.quality-priority button'))
+ .find((item) => item.textContent?.trim() === ${serializedLabel});
+ if (!(button instanceof HTMLButtonElement)) return false;
+ button.click();
+ return true;
+ })()`,
+ );
+ await waitForPage(
+ cdp,
+ hostPage,
+ `Array.from(document.querySelectorAll('.quality-priority button.is-selected')).some((item) => item.textContent?.trim() === ${serializedLabel})`,
+ 5_000,
+ `${label} advanced quality selection`,
+ signal,
+ );
+ await evaluate(
+ cdp,
+ hostPage,
+ `(() => {
+ const button = Array.from(document.querySelectorAll('.advanced-quality-grid > button'))
+ .find((item) => item.textContent?.includes('应用视频设置'));
+ if (!(button instanceof HTMLButtonElement)) return false;
+ button.click();
+ return true;
+ })()`,
+ );
+}
+
+async function runQualityControlSmoke(
+ cdp: CdpConnection,
+ pages: PageHandle[],
+ initialSettings: QualitySettings,
+ signal: AbortSignal,
+): Promise<{
+ peerConnectionsStable: boolean;
+}> {
+ const hostPage = pages[0];
+ if (!hostPage) {
+ throw new Error("Quality control smoke requires a host page");
+ }
+ const baseline = await Promise.all(pages.map((page) => quickSnapshot(cdp, page)));
+ const baselineFingerprint = peerConnectionFingerprint(baseline);
+ const balanced = {
+ ...initialSettings,
+ degradationPreference: "balanced",
+ } as const satisfies QualitySettings;
+
+ const waitForSettings = async (settings: QualitySettings): Promise => {
+ const serializedSettings = JSON.stringify(JSON.stringify(settings));
+ await Promise.all(
+ pages.map((page) =>
+ waitForPage(
+ cdp,
+ page,
+ `JSON.stringify(globalThis.__SCREENER_BENCHMARK__.snapshot().qualitySettings) === ${serializedSettings}`,
+ 10_000,
+ `${settings.degradationPreference} quality propagation`,
+ signal,
+ ),
+ ),
+ );
+ };
+ const sendingLabels = new Set(
+ baseline
+ .filter((page) => page.assignment.childPeerIds.length > 0)
+ .map((page) => page.label),
+ );
+ const sendingPages = pages.filter((page) => sendingLabels.has(page.label));
+ const waitForReadback = async (label: "平衡" | "清晰"): Promise => {
+ const expected = JSON.stringify(`${label} / ${label}`);
+ await Promise.all(
+ sendingPages.map((page) =>
+ waitForPage(
+ cdp,
+ page,
+ `Array.from(document.querySelectorAll('.metric')).some((metric) =>
+ metric.querySelector('dt')?.textContent?.trim() === '请求 / 应用优先级' &&
+ metric.querySelector('dd')?.textContent?.trim() === ${expected}
+ )`,
+ 10_000,
+ `${label} sender parameter readback`,
+ signal,
+ ),
+ ),
+ );
+ };
+
+ await applyQualityPreference(cdp, hostPage, "平衡", signal);
+ await waitForSettings(balanced);
+ await waitForReadback("平衡");
+ await applyQualityPreference(cdp, hostPage, "清晰优先", signal);
+ await waitForSettings(initialSettings);
+ await waitForReadback("清晰");
+
+ const final = await Promise.all(pages.map((page) => quickSnapshot(cdp, page)));
+
+ return {
+ peerConnectionsStable:
+ peerConnectionFingerprint(final) === baselineFingerprint,
+ };
+}
+
function totalDecodedFrames(page: PageObservation): number {
return page.connections.reduce(
(total, connection) => total + (connection.receiveTotals?.framesTotal ?? 0),
@@ -1450,10 +1598,11 @@ async function runCase(
let recovery: RecoveryResult = { triggered: false };
try {
const capture = PROFILE_SETTINGS[config.profileId];
+ const captureResolution = QUALITY_RESOLUTIONS[capture.resolution];
const commonInit = {
- width: capture.width,
- height: capture.height,
- frameRate: capture.frameRate,
+ width: captureResolution.width,
+ height: captureResolution.height,
+ frameRate: capture.maxFramerate,
};
const hostPage = await createPage(cdp, baseUrl, {
...commonInit,
@@ -1505,6 +1654,32 @@ async function runCase(
const summary = summarizeSamples(samples, viewerCount);
const checks = buildRunChecks(summary, viewerCount, config.profileId);
+ if (config.qualityControlSmoke && viewerCount >= 3) {
+ const qualityControl = await runQualityControlSmoke(
+ cdp,
+ pages,
+ capture,
+ signal,
+ );
+ checks.push({
+ name: "quality-control-propagation",
+ passed: true,
+ actual: true,
+ expected: "balanced and clarity settings reach every participant",
+ });
+ checks.push({
+ name: "quality-control-no-peer-rebuild",
+ passed: qualityControl.peerConnectionsStable,
+ actual: qualityControl.peerConnectionsStable,
+ expected: "peer connection identities stay unchanged",
+ });
+ checks.push({
+ name: "quality-control-sender-readback",
+ passed: true,
+ actual: true,
+ expected: "host and active relay display both sender preference readbacks",
+ });
+ }
if (config.recoveryViewerCount === viewerCount) {
recovery = await runRecovery(
cdp,
@@ -1589,6 +1764,7 @@ export async function main(): Promise {
}
const profile = PROFILE_SETTINGS[config.profileId];
+ const profileResolution = QUALITY_RESOLUTIONS[profile.resolution];
const report: BenchmarkReport = {
schemaVersion: 1,
startedAt: new Date().toISOString(),
@@ -1612,14 +1788,15 @@ export async function main(): Promise {
recoveryTimeoutMs: config.recoveryTimeoutMs,
headless: config.headless,
noSandbox: config.noSandbox,
+ qualityControlSmoke: config.qualityControlSmoke,
chromeExecutable: basename(config.chromePath),
output: config.outputPath ?? "stdout",
},
capture: {
kind: "deterministic-canvas",
- width: profile.width,
- height: profile.height,
- frameRate: profile.frameRate,
+ width: profileResolution.width,
+ height: profileResolution.height,
+ frameRate: profile.maxFramerate,
audio: false,
},
limitations: [
diff --git a/src/client/components/StatsGrid.tsx b/src/client/components/StatsGrid.tsx
index 8ed61c7..28e5d37 100644
--- a/src/client/components/StatsGrid.tsx
+++ b/src/client/components/StatsGrid.tsx
@@ -1,4 +1,5 @@
import type { ConnectionMetrics } from "../types";
+import type { VideoSenderParameterReadback } from "../media/quality";
function readableNumber(value: number | null, digits = 0): string {
return value === null || !Number.isFinite(value) ? "未知" : value.toFixed(digits);
@@ -14,6 +15,25 @@ function qualityReason(value: string | null): string {
return value ? (labels[value] ?? value) : "未知";
}
+function requestedApplied(
+ requested: number | string | null,
+ applied: number | string | null,
+ format: (value: number | string) => string,
+): string {
+ const requestedText = requested === null ? "?" : format(requested);
+ const appliedText = applied === null ? "未读回" : format(applied);
+ return `${requestedText} / ${appliedText}`;
+}
+
+function preferenceLabel(value: number | string): string {
+ const labels: Record = {
+ "maintain-resolution": "清晰",
+ balanced: "平衡",
+ "maintain-framerate": "流畅",
+ };
+ return labels[String(value)] ?? String(value);
+}
+
function Metric({ label, value, title }: { label: string; value: string; title?: string }) {
return (
@@ -26,9 +46,11 @@ function Metric({ label, value, title }: { label: string; value: string; title?:
export function StatsGrid({
metrics,
direction,
+ senderParameters,
}: {
metrics: ConnectionMetrics;
direction: "send" | "receive";
+ senderParameters?: VideoSenderParameterReadback | null;
}) {
const encoder = metrics.encoderImplementation
? `${metrics.encoderImplementation}${
@@ -82,6 +104,42 @@ export function StatsGrid({
value={`${readableNumber(metrics.intervalEncodeMs, 1)} ms`}
/>
+ {senderParameters && (
+ <>
+
`${(Number(value) / 1_000_000).toFixed(1)} Mbps`,
+ )}
+ />
+ `${Number(value).toFixed(0)} fps`,
+ )}
+ />
+ `${Number(value).toFixed(2)}x`,
+ )}
+ />
+
+ >
+ )}
>
) : (
<>
diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts
index 106d7f7..02dc763 100644
--- a/src/client/media/host-sfu-route.ts
+++ b/src/client/media/host-sfu-route.ts
@@ -19,6 +19,7 @@ interface HostPublisherTransport {
deactivate(): Promise;
replaceStream(stream: MediaStream): Promise;
updateProfile(profile: QualityProfile): Promise;
+ getQualityWarning?(): string | null;
disconnect(): Promise;
}
@@ -217,6 +218,12 @@ export class HostSfuRoute {
: Promise.resolve(true);
}
+ getQualityWarning(): string | null {
+ return this.active?.active
+ ? (this.active.publisher.getQualityWarning?.() ?? null)
+ : null;
+ }
+
replaceStream(stream: MediaStream): Promise {
return this.active
? this.active.publisher.replaceStream(stream).catch(() => false)
diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts
index 4a65dbf..14e0533 100644
--- a/src/client/media/quality.ts
+++ b/src/client/media/quality.ts
@@ -1,47 +1,110 @@
-import type { QualityProfileId } from "../../shared/protocol";
+import {
+ DEFAULT_QUALITY_SETTINGS,
+ type DegradationPreference,
+ type QualityProfileId,
+ type QualityResolution,
+ type QualitySettings,
+} from "../../shared/protocol";
-export type { QualityProfileId } from "../../shared/protocol";
+export type {
+ DegradationPreference,
+ QualityProfileId,
+ QualityResolution,
+ QualitySettings,
+} from "../../shared/protocol";
+
+export type QualityProfile = QualitySettings;
export const QUALITY_PROFILES = {
- "1080p60": {
- label: "1080p 60",
- width: 1920,
- height: 1080,
- frameRate: 60,
- maxBitrate: 8_000_000,
- },
+ "1080p60": DEFAULT_QUALITY_SETTINGS,
"1080p30": {
- label: "1080p 30",
- width: 1920,
- height: 1080,
- frameRate: 30,
+ resolution: "1080p",
+ maxFramerate: 30,
maxBitrate: 5_000_000,
+ degradationPreference: "maintain-resolution",
},
"720p30": {
- label: "720p 30",
- width: 1280,
- height: 720,
- frameRate: 30,
+ resolution: "720p",
+ maxFramerate: 30,
maxBitrate: 3_000_000,
+ degradationPreference: "maintain-resolution",
},
+} as const satisfies Record;
+
+export const QUALITY_PROFILE_LABELS = {
+ "1080p60": "1080p 60",
+ "1080p30": "1080p 30",
+ "720p30": "720p 30",
+} as const satisfies Record;
+
+export const QUALITY_RESOLUTIONS = {
+ "720p": { width: 1280, height: 720, label: "720p" },
+ "1080p": { width: 1920, height: 1080, label: "1080p" },
+ "1440p": { width: 2560, height: 1440, label: "1440p" },
} as const satisfies Record<
- QualityProfileId,
- {
- label: string;
- width: number;
- height: number;
- frameRate: number;
- maxBitrate: number;
- }
+ QualityResolution,
+ { width: number; height: number; label: string }
>;
-export type QualityProfile = (typeof QUALITY_PROFILES)[QualityProfileId];
+export const DEGRADATION_PREFERENCE_LABELS = {
+ "maintain-resolution": "清晰优先",
+ balanced: "平衡",
+ "maintain-framerate": "流畅优先",
+} as const satisfies Record;
+
+export interface VideoSenderParameterValues {
+ maxBitrate: number | null;
+ maxFramerate: number | null;
+ scaleResolutionDownBy: number | null;
+ degradationPreference: RTCDegradationPreference | null;
+}
+
+export interface VideoSenderParameterReadback {
+ requested: VideoSenderParameterValues;
+ applied: VideoSenderParameterValues;
+ mismatches: Array;
+}
+
+export function qualitySettingsEqual(
+ left: QualitySettings,
+ right: QualitySettings,
+): boolean {
+ return (
+ left.resolution === right.resolution &&
+ left.maxFramerate === right.maxFramerate &&
+ left.maxBitrate === right.maxBitrate &&
+ left.degradationPreference === right.degradationPreference
+ );
+}
+
+export function matchingQualityProfileId(
+ settings: QualitySettings,
+): QualityProfileId | null {
+ for (const id of Object.keys(QUALITY_PROFILES) as QualityProfileId[]) {
+ if (qualitySettingsEqual(settings, QUALITY_PROFILES[id])) {
+ return id;
+ }
+ }
+ return null;
+}
+
+export function qualitySettingsLabel(settings: QualitySettings): string {
+ const profileId = matchingQualityProfileId(settings);
+ if (profileId) {
+ return QUALITY_PROFILE_LABELS[profileId];
+ }
+ return `${QUALITY_RESOLUTIONS[settings.resolution].label} ${settings.maxFramerate} · ${(settings.maxBitrate / 1_000_000).toFixed(1)} Mbps · ${DEGRADATION_PREFERENCE_LABELS[settings.degradationPreference]}`;
+}
function captureConstraints(profile: QualityProfile): MediaTrackConstraints {
+ const resolution = QUALITY_RESOLUTIONS[profile.resolution];
return {
- width: { ideal: profile.width, max: profile.width },
- height: { ideal: profile.height, max: profile.height },
- frameRate: { ideal: profile.frameRate, max: profile.frameRate },
+ width: { ideal: resolution.width, max: resolution.width },
+ height: { ideal: resolution.height, max: resolution.height },
+ frameRate: {
+ ideal: profile.maxFramerate,
+ max: profile.maxFramerate,
+ },
};
}
@@ -86,16 +149,87 @@ export function setVideoPaused(stream: MediaStream, paused: boolean): boolean {
return true;
}
+function requestedScaleResolutionDownBy(
+ sender: RTCRtpSender,
+ profile: QualityProfile,
+): number {
+ const source =
+ sender.track && typeof sender.track.getSettings === "function"
+ ? sender.track.getSettings()
+ : undefined;
+ const ceiling = QUALITY_RESOLUTIONS[profile.resolution];
+ if (!source?.width || !source.height) {
+ return 1;
+ }
+ return Math.max(
+ 1,
+ source.width / ceiling.width,
+ source.height / ceiling.height,
+ );
+}
+
+function readVideoSenderParameters(
+ parameters: RTCRtpSendParameters,
+): VideoSenderParameterValues {
+ const encoding = parameters.encodings[0];
+ return {
+ maxBitrate: encoding?.maxBitrate ?? null,
+ maxFramerate: encoding?.maxFramerate ?? null,
+ scaleResolutionDownBy: encoding?.scaleResolutionDownBy ?? null,
+ degradationPreference: parameters.degradationPreference ?? null,
+ };
+}
+
+function sameParameter(
+ key: keyof VideoSenderParameterValues,
+ requested: number | RTCDegradationPreference | null,
+ applied: number | RTCDegradationPreference | null,
+): boolean {
+ if (typeof requested === "number" && typeof applied === "number") {
+ return key === "scaleResolutionDownBy"
+ ? Math.abs(requested - applied) < 0.01
+ : requested === applied;
+ }
+ return requested === applied;
+}
+
export async function configureVideoSender(
sender: RTCRtpSender,
profile: QualityProfile,
-): Promise {
+): Promise {
const parameters = sender.getParameters();
if (parameters.encodings.length === 0) {
parameters.encodings = [{}];
}
- parameters.encodings[0].maxBitrate = profile.maxBitrate;
- parameters.encodings[0].maxFramerate = profile.frameRate;
- parameters.degradationPreference = "balanced";
+ parameters.encodings[0]!.maxBitrate = profile.maxBitrate;
+ parameters.encodings[0]!.maxFramerate = profile.maxFramerate;
+ parameters.encodings[0]!.scaleResolutionDownBy =
+ requestedScaleResolutionDownBy(sender, profile);
+ parameters.degradationPreference = profile.degradationPreference;
+
+ const requested = readVideoSenderParameters(parameters);
await sender.setParameters(parameters);
+ const applied = readVideoSenderParameters(sender.getParameters());
+ const mismatches = (
+ Object.keys(requested) as Array
+ ).filter((key) => !sameParameter(key, requested[key], applied[key]));
+
+ return { requested, applied, mismatches };
+}
+
+const PARAMETER_LABELS = {
+ maxBitrate: "码率上限",
+ maxFramerate: "帧率上限",
+ scaleResolutionDownBy: "分辨率缩放",
+ degradationPreference: "质量优先级",
+} as const satisfies Record;
+
+export function senderParameterWarning(
+ readback: VideoSenderParameterReadback,
+): string | null {
+ return readback.mismatches.length > 0
+ ? `浏览器未完整接受${readback.mismatches
+ .map((key) => PARAMETER_LABELS[key])
+ .join("、")}`
+ : null;
}
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index 38d268e..e667e74 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
- DEFAULT_QUALITY_PROFILE_ID,
+ DEFAULT_QUALITY_SETTINGS,
type CreateRoomResponse,
type IceConfig,
type ServerMessage,
@@ -37,9 +37,16 @@ import { SignalingClient } from "../lib/signaling";
import {
applyCaptureProfile,
captureDisplay,
+ DEGRADATION_PREFERENCE_LABELS,
+ matchingQualityProfileId,
QUALITY_PROFILES,
+ QUALITY_PROFILE_LABELS,
+ QUALITY_RESOLUTIONS,
+ qualitySettingsLabel,
setVideoPaused,
+ type DegradationPreference,
type QualityProfileId,
+ type QualitySettings,
} from "../media/quality";
import { HostSfuRoute } from "../media/host-sfu-route";
import type {
@@ -113,8 +120,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
() => new URLSearchParams(window.location.search).get("relay") === "1",
[],
);
- const [qualityId, setQualityId] = useState(
- DEFAULT_QUALITY_PROFILE_ID,
+ const [qualitySettings, setQualitySettings] = useState(
+ DEFAULT_QUALITY_SETTINGS,
+ );
+ const [advancedQuality, setAdvancedQuality] = useState(
+ DEFAULT_QUALITY_SETTINGS,
);
const shareGenerationRef = useRef(null);
const [phase, setPhase] = useState("idle");
@@ -144,7 +154,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
const activeGenerationRef = useRef(null);
const sourceSwitchRef = useRef(null);
const qualityChangeRef = useRef(null);
- const qualityIdRef = useRef(DEFAULT_QUALITY_PROFILE_ID);
+ const qualitySettingsRef = useRef(DEFAULT_QUALITY_SETTINGS);
const picturePausedRef = useRef(false);
const retiringStreamRef = useRef(null);
const hostSfuRouteRef = useRef(null);
@@ -153,6 +163,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
() => Array.from(peerSnapshots.values()),
[peerSnapshots],
);
+ const selectedQualityProfileId = useMemo(
+ () => matchingQualityProfileId(qualitySettings),
+ [qualitySettings],
+ );
useEffect(() => {
if (videoRef.current) {
@@ -194,7 +208,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
let route: HostSfuRoute;
route = new HostSfuRoute({
getStream: () => streamRef.current,
- getProfile: () => QUALITY_PROFILES[qualityIdRef.current],
+ getProfile: () => qualitySettingsRef.current,
reconcileChildren: (childPeerIds) => {
if (
isCurrentGeneration(generation) &&
@@ -309,14 +323,15 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
setSwitchingSource(false);
}
- function commitQuality(id: QualityProfileId): void {
- qualityIdRef.current = id;
- setQualityId(id);
+ function commitQuality(settings: QualitySettings): void {
+ qualitySettingsRef.current = settings;
+ setQualitySettings(settings);
+ setAdvancedQuality(settings);
}
- async function changeQuality(nextId: QualityProfileId): Promise {
+ async function changeQuality(nextProfile: QualitySettings): Promise {
if (phase !== "live") {
- commitQuality(nextId);
+ commitQuality(nextProfile);
return;
}
@@ -336,10 +351,9 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
qualityChangeRef.current = token;
setChangingQuality(true);
setNotice(null);
- const profile = QUALITY_PROFILES[nextId];
try {
- await applyCaptureProfile(activeStream, profile);
+ await applyCaptureProfile(activeStream, nextProfile);
if (
!isCurrentGeneration(generation) ||
qualityChangeRef.current !== token ||
@@ -348,29 +362,38 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
return;
}
- commitQuality(nextId);
+ commitQuality(nextProfile);
setDetails(captureDetails(activeStream));
if (peerAssistedRef.current) {
signalRef.current?.send({
- type: "set-quality-profile",
- qualityProfileId: nextId,
+ type: "set-quality-settings",
+ qualitySettings: nextProfile,
});
}
+ const activeSfuRoute = hostSfuRouteRef.current;
const [results, sfuUpdated] = await Promise.all([
Promise.all(
- [...peersRef.current.values()].map((peer) => peer.updateProfile(profile)),
+ [...peersRef.current.values()].map((peer) =>
+ peer.updateProfile(nextProfile),
+ ),
),
- hostSfuRouteRef.current?.updateProfile(profile) ?? Promise.resolve(true),
+ activeSfuRoute?.updateProfile(nextProfile) ??
+ Promise.resolve(true),
]);
if (
isCurrentGeneration(generation) &&
qualityChangeRef.current === token
) {
const failed = results.filter((updated) => !updated).length;
+ const sfuWarning =
+ hostSfuRouteRef.current === activeSfuRoute
+ ? (activeSfuRoute?.getQualityWarning() ?? null)
+ : null;
setNotice(
- failed > 0 || !sfuUpdated
- ? "画质已切换,但部分观看连接未能应用新参数"
- : `画质已切换为 ${profile.label}`,
+ sfuWarning ??
+ (failed > 0 || !sfuUpdated
+ ? "画质已切换,但部分观看连接未能应用新参数"
+ : `画质已切换为 ${qualitySettingsLabel(nextProfile)}`),
);
}
} catch (error) {
@@ -442,7 +465,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
peerId,
iceConfig,
activeStream,
- QUALITY_PROFILES[qualityIdRef.current],
+ qualitySettingsRef.current,
{
sendSignal: (targetPeerId, payload) =>
isCurrentGeneration(generation) && signalRef.current === signal
@@ -565,8 +588,8 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
) {
peerAssistedRef.current = true;
signalRef.current?.send({
- type: "set-quality-profile",
- qualityProfileId: qualityIdRef.current,
+ type: "set-quality-settings",
+ qualitySettings: qualitySettingsRef.current,
});
void ensureHostSfuRoute(generation).resyncAuthoritative(
{
@@ -696,7 +719,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
let captured: MediaStream;
try {
// This must remain the first awaited operation in the button gesture.
- captured = await captureDisplay(QUALITY_PROFILES[qualityIdRef.current]);
+ captured = await captureDisplay(qualitySettingsRef.current);
} catch (error) {
if (!isCurrentGeneration(generation)) {
return;
@@ -851,7 +874,7 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
let captured: MediaStream;
try {
// Like initial capture, changing source must begin in this button gesture.
- captured = await captureDisplay(QUALITY_PROFILES[qualityIdRef.current]);
+ captured = await captureDisplay(qualitySettingsRef.current);
} catch (error) {
if (
isCurrentGeneration(generation) &&
@@ -1106,29 +1129,149 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
)}
-
- 画质
-
- {(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map(
- (id) => (
-
+
+ 推荐画质
+
+ {(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map(
+ (id) => (
+ void changeQuality(QUALITY_PROFILES[id])}
+ >
+ {QUALITY_PROFILE_LABELS[id]}
+
+ ),
+ )}
+
+
+
+
+ 高级视频设置
+
+
+ 分辨率上限
+
+ setAdvancedQuality((current) => ({
+ ...current,
+ resolution: event.target
+ .value as QualitySettings["resolution"],
+ }))
}
- onClick={() => void changeQuality(id)}
>
- {QUALITY_PROFILES[id].label}
-
- ),
- )}
-
-
+ {Object.entries(QUALITY_RESOLUTIONS).map(
+ ([resolution, option]) => (
+
+ {option.label}
+
+ ),
+ )}
+
+
+
+ 帧率上限
+
+
+ setAdvancedQuality((current) => ({
+ ...current,
+ maxFramerate: Number(event.target.value),
+ }))
+ }
+ />
+ {advancedQuality.maxFramerate} fps
+
+
+
+ 视频码率上限
+
+
+ setAdvancedQuality((current) => ({
+ ...current,
+ maxBitrate: Number(event.target.value),
+ }))
+ }
+ />
+
+ {(advancedQuality.maxBitrate / 1_000_000).toFixed(1)} Mbps
+
+
+
+
+ 质量优先级
+
+ {(
+ Object.keys(
+ DEGRADATION_PREFERENCE_LABELS,
+ ) as DegradationPreference[]
+ ).map((preference) => (
+
+ setAdvancedQuality((current) => ({
+ ...current,
+ degradationPreference: preference,
+ }))
+ }
+ >
+ {DEGRADATION_PREFERENCE_LABELS[preference]}
+
+ ))}
+
+
+ void changeQuality(advancedQuality)}
+ >
+ {changingQuality ? "正在应用" : "应用视频设置"}
+
+
+
+
{phase !== "live" && (
-
+
{viewer.error && {viewer.error}
}
+ {viewer.qualityWarning && (
+ {viewer.qualityWarning}
+ )}
))}
{viewers.length === 0 && (
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx
index c25be61..74f0953 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -8,7 +8,7 @@ import {
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
- DEFAULT_QUALITY_PROFILE_ID,
+ DEFAULT_QUALITY_SETTINGS,
type IceConfig,
type MediaAssignment,
type ServerMessage,
@@ -23,10 +23,7 @@ import {
import { StatsGrid } from "../components/StatsGrid";
import { getStableClientId } from "../lib/session";
import { SignalingClient } from "../lib/signaling";
-import {
- QUALITY_PROFILES,
- type QualityProfileId,
-} from "../media/quality";
+import type { QualitySettings } from "../media/quality";
import { relayCapacityMessageForBrowser } from "../media/relay-capability";
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
import { ViewerSfuRoute } from "../media/viewer-sfu-route";
@@ -72,7 +69,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
let currentIceConfig: IceConfig | null = null;
let currentHostOnline = false;
let peerAssisted = false;
- let currentQualityProfileId: QualityProfileId = DEFAULT_QUALITY_PROFILE_ID;
+ let currentQualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS;
let currentAssignment: MediaAssignment = {
parentPeerId: null,
childPeerIds: [],
@@ -136,7 +133,7 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
viewerRelay = new ViewerRelay(
currentIceConfig,
- QUALITY_PROFILES[currentQualityProfileId],
+ currentQualitySettings,
{
sendSignal: (targetPeerId, payload) =>
active &&
@@ -393,11 +390,9 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
currentHostOnline = message.hostOnline;
setRelayAvailable(message.iceConfig.relayAvailable);
setHostOnline(message.hostOnline);
- if (nextPeerAssisted && "qualityProfileId" in message) {
- currentQualityProfileId = message.qualityProfileId;
- void viewerRelay?.updateProfile(
- QUALITY_PROFILES[currentQualityProfileId],
- );
+ if (nextPeerAssisted && "qualitySettings" in message) {
+ currentQualitySettings = message.qualitySettings;
+ void viewerRelay?.updateProfile(currentQualitySettings);
await ensureViewerSfuRoute().resyncAuthoritative(
{
revision: message.routeRevision,
@@ -464,12 +459,10 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
}
return;
}
- if (message.type === "quality-profile") {
+ if (message.type === "quality-settings") {
if (peerAssisted) {
- currentQualityProfileId = message.qualityProfileId;
- void viewerRelay?.updateProfile(
- QUALITY_PROFILES[currentQualityProfileId],
- );
+ currentQualitySettings = message.qualitySettings;
+ void viewerRelay?.updateProfile(currentQualitySettings);
}
return;
}
@@ -745,7 +738,17 @@ export function ViewerPage({ roomId, onAuthorizationRequired }: ViewerPageProps)
{relaySnapshot && (
转发数据
-
+
+ {relaySnapshot.error && (
+ {relaySnapshot.error}
+ )}
+ {relaySnapshot.qualityWarning && (
+ {relaySnapshot.qualityWarning}
+ )}
)}
diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts
index 43cabd7..5773272 100644
--- a/src/client/sfu/publisher.ts
+++ b/src/client/sfu/publisher.ts
@@ -7,7 +7,9 @@ import type {
import {
configureVideoSender,
+ senderParameterWarning,
type QualityProfile,
+ type VideoSenderParameterReadback,
} from "../media/quality";
export interface SfuConnectionConfig {
@@ -40,6 +42,7 @@ export class SfuPublisher {
private video: PublishedTrack | null = null;
private audio: PublishedTrack | null = null;
private profile: QualityProfile | null = null;
+ private qualityWarning: string | null = null;
private state: PublisherState = "idle";
private generation = 0;
private operationTail: Promise = Promise.resolve();
@@ -136,6 +139,7 @@ export class SfuPublisher {
this.video = video;
this.audio = audio;
this.profile = profile;
+ this.qualityWarning = null;
this.state = "active";
return true;
} catch (error) {
@@ -183,6 +187,7 @@ export class SfuPublisher {
this.video = null;
this.audio = null;
this.profile = null;
+ this.qualityWarning = null;
this.state = "prepared";
return true;
});
@@ -297,21 +302,27 @@ export class SfuPublisher {
}
try {
- await configurePublishedVideo(video, profile);
+ const readback = await configurePublishedVideo(video, profile);
if (!this.owns(room, generation)) {
return false;
}
this.profile = profile;
+ this.qualityWarning = senderParameterWarning(readback);
return true;
} catch (error) {
if (!this.owns(room, generation)) {
return false;
}
+ const failureWarning =
+ error instanceof Error && error.message
+ ? `应用 SFU 发送参数失败:${error.message}`
+ : "应用 SFU 发送参数失败";
try {
await configurePublishedVideo(video, previousProfile);
if (!this.owns(room, generation)) {
return false;
}
+ this.qualityWarning = failureWarning;
return false;
} catch (rollbackError) {
if (this.owns(room, generation)) {
@@ -323,6 +334,10 @@ export class SfuPublisher {
});
}
+ getQualityWarning(): string | null {
+ return this.qualityWarning;
+ }
+
async disconnect(): Promise {
const room = this.invalidate();
if (room) {
@@ -378,6 +393,7 @@ export class SfuPublisher {
this.video = null;
this.audio = null;
this.profile = null;
+ this.qualityWarning = null;
return room;
}
@@ -437,17 +453,18 @@ async function unpublishTrack(room: Room, published: PublishedTrack): Promise {
+): Promise {
const videoTrack = published.publication.videoTrack;
const sender = videoTrack?.sender;
if (!sender) {
throw new Error("SFU video publication has no RTP sender");
}
- await configureVideoSender(sender, profile);
+ const readback = await configureVideoSender(sender, profile);
videoTrack.publishOptions = {
...videoTrack.publishOptions,
...videoPublishOptions(profile),
};
+ return readback;
}
function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
@@ -455,9 +472,9 @@ function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
simulcast: false,
screenShareEncoding: {
maxBitrate: profile.maxBitrate,
- maxFramerate: profile.frameRate,
+ maxFramerate: profile.maxFramerate,
},
- degradationPreference: "balanced",
+ degradationPreference: profile.degradationPreference,
};
}
diff --git a/src/client/styles.css b/src/client/styles.css
index 5cc02c6..22c095c 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -44,7 +44,8 @@ body {
}
button,
-input {
+input,
+select {
font: inherit;
letter-spacing: 0;
}
@@ -55,6 +56,7 @@ button {
button:focus-visible,
input:focus-visible,
+select:focus-visible,
a:focus-visible {
outline: 2px solid var(--green);
outline-offset: 2px;
@@ -298,6 +300,81 @@ h3 {
margin-top: 22px;
}
+.quality-controls {
+ min-width: 0;
+}
+
+.advanced-quality {
+ margin-top: 10px;
+ border-top: 1px solid var(--border);
+}
+
+.advanced-quality summary {
+ width: fit-content;
+ padding-top: 10px;
+ color: var(--muted);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.advanced-quality summary:hover {
+ color: var(--text);
+}
+
+.advanced-quality-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px 18px;
+ align-items: end;
+ padding-top: 14px;
+}
+
+.advanced-quality-grid > label > span {
+ display: block;
+ margin-bottom: 7px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.advanced-quality-grid select {
+ width: 100%;
+ height: 40px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ background: var(--surface);
+}
+
+.range-control {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 92px;
+ min-height: 40px;
+ gap: 10px;
+ align-items: center;
+}
+
+.range-control input {
+ width: 100%;
+ accent-color: var(--green);
+}
+
+.range-control output {
+ color: var(--text);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.quality-priority {
+ grid-column: 1 / -1;
+}
+
+.advanced-quality-grid > .button {
+ justify-self: start;
+}
+
.join-room-link {
display: inline-flex;
grid-column: 1 / -1;
@@ -647,6 +724,13 @@ h3 {
line-height: 1.4;
}
+.inline-warning {
+ margin-top: 10px;
+ color: var(--amber);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
.viewer-workspace {
width: min(1380px, 100%);
margin: 0 auto;
@@ -737,6 +821,14 @@ h3 {
grid-template-columns: minmax(0, 1fr) auto;
}
+ .advanced-quality-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .quality-priority {
+ grid-column: auto;
+ }
+
.start-button {
grid-column: auto;
}
diff --git a/src/client/types.ts b/src/client/types.ts
index 5269cc3..4179996 100644
--- a/src/client/types.ts
+++ b/src/client/types.ts
@@ -35,6 +35,8 @@ export interface PeerSnapshot {
iceConnectionState: RTCIceConnectionState;
metrics: ConnectionMetrics;
error: string | null;
+ senderParameters?: import("./media/quality").VideoSenderParameterReadback | null;
+ qualityWarning?: string | null;
}
export const EMPTY_METRICS: ConnectionMetrics = {
diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts
index 6fabc94..fa60623 100644
--- a/src/client/webrtc/host-peer.ts
+++ b/src/client/webrtc/host-peer.ts
@@ -1,7 +1,10 @@
import type { IceConfig, SignalPayload } from "../../shared/protocol";
import { createOpaqueId } from "../lib/opaque-id";
import type { QualityProfile } from "../media/quality";
-import { configureVideoSender } from "../media/quality";
+import {
+ configureVideoSender,
+ senderParameterWarning,
+} from "../media/quality";
import {
EMPTY_METRICS,
type PeerSnapshot,
@@ -32,6 +35,9 @@ export class HostPeer {
private audioSender: RTCRtpSender | null = null;
private statsTimer: number | null = null;
private statsInFlight = false;
+ private senderWarning: string | null = null;
+ private limitationReason: string | null = null;
+ private limitationSamples = 0;
private disposed = false;
private negotiating = false;
private senderMutationTail: Promise = Promise.resolve();
@@ -56,6 +62,8 @@ export class HostPeer {
iceConnectionState: this.connection.iceConnectionState,
metrics: { ...EMPTY_METRICS },
error: null,
+ senderParameters: null,
+ qualityWarning: null,
};
this.bindConnectionEvents();
}
@@ -75,16 +83,12 @@ export class HostPeer {
direction: "sendonly",
streams: [this.stream],
}).sender;
- try {
- await this.enqueueSenderMutation(async () => {
- if (this.disposed || !this.videoSender) {
- return;
- }
- await configureVideoSender(this.videoSender, this.desiredProfile);
- });
- } catch (error) {
- console.warn("Browser rejected preferred sender parameters", error);
- }
+ await this.enqueueSenderMutation(async () => {
+ if (this.disposed || !this.videoSender) {
+ return false;
+ }
+ return this.configureSender(this.videoSender);
+ });
if (!(await this.createOffer(false)) || this.disposed) {
return false;
}
@@ -129,11 +133,7 @@ export class HostPeer {
}
this.stream = nextStream;
this.statsAccumulator = createStatsAccumulator();
- try {
- await configureVideoSender(videoSender, this.desiredProfile);
- } catch (error) {
- console.warn("Browser rejected preferred sender parameters", error);
- }
+ await this.configureSender(videoSender);
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
@@ -150,16 +150,15 @@ export class HostPeer {
if (this.disposed || !videoSender) {
return false;
}
- try {
- await configureVideoSender(videoSender, this.desiredProfile);
- } catch (error) {
- this.setError(error, "调整画质失败");
+ if (!(await this.configureSender(videoSender))) {
return false;
}
if (this.disposed) {
return false;
}
this.statsAccumulator = createStatsAccumulator();
+ this.limitationReason = null;
+ this.limitationSamples = 0;
this.snapshot = { ...this.snapshot, error: null };
this.emit();
return true;
@@ -325,7 +324,13 @@ export class HostPeer {
if (this.disposed || this.statsAccumulator !== statsAccumulator) {
return;
}
- this.snapshot = { ...this.snapshot, metrics };
+ this.updateLimitationWarning(metrics.qualityLimitationReason);
+ this.snapshot = {
+ ...this.snapshot,
+ metrics,
+ qualityWarning:
+ this.senderWarning ?? this.persistentLimitationWarning(),
+ };
this.emit();
} catch {
// Stats are observational and must never disrupt a healthy media path.
@@ -340,6 +345,73 @@ export class HostPeer {
this.emit();
}
+ private async configureSender(sender: RTCRtpSender): Promise {
+ try {
+ const senderParameters = await configureVideoSender(
+ sender,
+ this.desiredProfile,
+ );
+ if (this.disposed) {
+ return false;
+ }
+ this.senderWarning = senderParameterWarning(senderParameters);
+ this.snapshot = {
+ ...this.snapshot,
+ senderParameters,
+ qualityWarning:
+ this.senderWarning ?? this.persistentLimitationWarning(),
+ };
+ this.emit();
+ return true;
+ } catch (error) {
+ if (this.disposed) {
+ return false;
+ }
+ this.senderWarning =
+ error instanceof Error && error.message
+ ? `应用发送参数失败:${error.message}`
+ : "应用发送参数失败";
+ this.snapshot = {
+ ...this.snapshot,
+ qualityWarning: this.senderWarning,
+ };
+ this.emit();
+ return false;
+ }
+ }
+
+ private updateLimitationWarning(reason: string | null): void {
+ if (!reason || reason === "none") {
+ this.limitationReason = null;
+ this.limitationSamples = 0;
+ return;
+ }
+ if (reason === this.limitationReason) {
+ this.limitationSamples += 1;
+ return;
+ }
+ this.limitationReason = reason;
+ this.limitationSamples = 1;
+ }
+
+ private persistentLimitationWarning(): string | null {
+ if (this.limitationSamples < 3) {
+ return null;
+ }
+ switch (this.limitationReason) {
+ case "bandwidth":
+ return "持续受带宽限制,浏览器正在降低画面质量";
+ case "cpu":
+ return "持续受编码性能限制,浏览器正在降低画面质量";
+ case "other":
+ return "浏览器持续报告其他画质限制";
+ default:
+ return this.limitationReason
+ ? `浏览器持续报告画质限制:${this.limitationReason}`
+ : null;
+ }
+ }
+
private emit(): void {
if (this.disposed) {
return;
diff --git a/src/server/signaling.ts b/src/server/signaling.ts
index 6811a1b..1fcdf0b 100644
--- a/src/server/signaling.ts
+++ b/src/server/signaling.ts
@@ -5,11 +5,11 @@ import type { Duplex } from "node:stream";
import WebSocket, { WebSocketServer } from "ws";
import {
- DEFAULT_QUALITY_PROFILE_ID,
+ DEFAULT_QUALITY_SETTINGS,
MAX_SIGNAL_BYTES,
decodeClientMessage,
type ClientMessage,
- type QualityProfileId,
+ type QualitySettings,
type Role,
type ServerMessage,
} from "../shared/protocol.js";
@@ -69,10 +69,7 @@ export class SignalingServer {
private readonly socketsBySessionId = new Map();
private readonly viewerGraceTimers = new Map();
private readonly connectionIdsByViewer = new Map();
- private readonly qualityProfileIdsByRoom = new Map<
- string,
- QualityProfileId
- >();
+ private readonly qualitySettingsByRoom = new Map();
private readonly shareGenerationsByRoom = new Map();
private readonly hybridMediaRouter?: HybridMediaRouter;
private readonly now: () => number;
@@ -385,9 +382,9 @@ export class SignalingServer {
mediaAssignment: hybridState.mediaAssignment,
routeRevision: hybridState.routeRevision,
routeAssignment: hybridState.routeAssignment,
- qualityProfileId:
- this.qualityProfileIdsByRoom.get(participant.roomId) ??
- DEFAULT_QUALITY_PROFILE_ID,
+ qualitySettings:
+ this.qualitySettingsByRoom.get(participant.roomId) ??
+ DEFAULT_QUALITY_SETTINGS,
});
} else {
this.send(socket, authenticatedMessage);
@@ -473,25 +470,25 @@ export class SignalingServer {
),
});
return;
- case "set-quality-profile":
+ case "set-quality-settings":
if (!this.options.peerAssistedMedia || authenticated.role !== "host") {
this.sendError(
socket,
"FORBIDDEN",
- "Only a peer-assisted host may set the quality profile",
+ "Only a peer-assisted host may set quality settings",
);
return;
}
- this.qualityProfileIdsByRoom.set(
+ this.qualitySettingsByRoom.set(
authenticated.roomId,
- message.qualityProfileId,
+ { ...message.qualitySettings },
);
for (const viewer of this.options.roomStore.getConnectedViewers(
authenticated.roomId,
)) {
this.sendToSession(viewer.sessionId, {
- type: "quality-profile",
- qualityProfileId: message.qualityProfileId,
+ type: "quality-settings",
+ qualitySettings: message.qualitySettings,
});
}
return;
@@ -841,7 +838,7 @@ export class SignalingServer {
this.clearRoomGraceTimers(roomId);
this.clearRoomConnectionIds(roomId);
this.hybridMediaRouter?.deleteRoom(roomId);
- this.qualityProfileIdsByRoom.delete(roomId);
+ this.qualitySettingsByRoom.delete(roomId);
this.shareGenerationsByRoom.delete(roomId);
for (const sessionId of abandoned.sessionIds) {
const socket = this.socketsBySessionId.get(sessionId);
@@ -858,7 +855,7 @@ export class SignalingServer {
this.clearRoomGraceTimers(expired.roomId);
this.clearRoomConnectionIds(expired.roomId);
this.hybridMediaRouter?.deleteRoom(expired.roomId);
- this.qualityProfileIdsByRoom.delete(expired.roomId);
+ this.qualitySettingsByRoom.delete(expired.roomId);
this.shareGenerationsByRoom.delete(expired.roomId);
for (const sessionId of expired.sessionIds) {
const socket = this.socketsBySessionId.get(sessionId);
diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts
index c5ec504..cfcbd0e 100644
--- a/src/shared/protocol.ts
+++ b/src/shared/protocol.ts
@@ -35,6 +35,34 @@ export const qualityProfileIdSchema = z.enum([
export type QualityProfileId = z.infer;
export const DEFAULT_QUALITY_PROFILE_ID: QualityProfileId = "1080p60";
+export const qualityResolutionSchema = z.enum(["720p", "1080p", "1440p"]);
+export type QualityResolution = z.infer;
+
+export const degradationPreferenceSchema = z.enum([
+ "maintain-resolution",
+ "balanced",
+ "maintain-framerate",
+]);
+export type DegradationPreference = z.infer<
+ typeof degradationPreferenceSchema
+>;
+
+export const qualitySettingsSchema = z
+ .object({
+ resolution: qualityResolutionSchema,
+ maxFramerate: z.number().int().min(15).max(60),
+ maxBitrate: z.number().int().min(2_000_000).max(12_000_000),
+ degradationPreference: degradationPreferenceSchema,
+ })
+ .strict();
+export type QualitySettings = z.infer;
+export const DEFAULT_QUALITY_SETTINGS = {
+ resolution: "1080p",
+ maxFramerate: 60,
+ maxBitrate: 8_000_000,
+ degradationPreference: "maintain-resolution",
+} as const satisfies QualitySettings;
+
export const relayDownstreamEdgesSchema = z.union([
z.literal(0),
z.literal(1),
@@ -181,8 +209,8 @@ export const clientMessageSchema = z.union([
z.object({ type: z.literal("refresh-ice") }).strict(),
z
.object({
- type: z.literal("set-quality-profile"),
- qualityProfileId: qualityProfileIdSchema,
+ type: z.literal("set-quality-settings"),
+ qualitySettings: qualitySettingsSchema,
})
.strict(),
z
@@ -257,7 +285,7 @@ const authenticatedMessageSchema = z.union([
mediaAssignment: mediaAssignmentSchema,
routeRevision: mediaRouteRevisionSchema,
routeAssignment: participantRouteAssignmentSchema,
- qualityProfileId: qualityProfileIdSchema,
+ qualitySettings: qualitySettingsSchema,
})
.strict(),
]);
@@ -315,8 +343,8 @@ export const serverMessageSchema = z.union([
.strict(),
z
.object({
- type: z.literal("quality-profile"),
- qualityProfileId: qualityProfileIdSchema,
+ type: z.literal("quality-settings"),
+ qualitySettings: qualitySettingsSchema,
})
.strict(),
z
diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts
index afc738f..dfd8e8e 100644
--- a/tests/host-peer-tracks.test.ts
+++ b/tests/host-peer-tracks.test.ts
@@ -12,6 +12,7 @@ class FakeSender {
failNextSetParameters = false;
deferNextSetParameters = false;
readonly appliedMaxBitrates: Array = [];
+ private parameters = { encodings: [{}] } as RTCRtpSendParameters;
private releaseSetParameters: (() => void) | null = null;
readonly setParameters = vi.fn(
async (parameters: RTCRtpSendParameters) => {
@@ -26,6 +27,7 @@ class FakeSender {
});
}
this.appliedMaxBitrates.push(parameters.encodings[0]?.maxBitrate);
+ this.parameters = parameters;
},
);
readonly replaceTrack = vi.fn(async (track: MediaStreamTrack | null) => {
@@ -39,7 +41,7 @@ class FakeSender {
constructor(public track: MediaStreamTrack | null) {}
getParameters(): RTCRtpSendParameters {
- return { encodings: [{}] } as RTCRtpSendParameters;
+ return this.parameters;
}
releaseDeferredSetParameters(): void {
@@ -331,7 +333,7 @@ describe("HostPeer source replacement", () => {
expect(
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
).toMatchObject({
- degradationPreference: "balanced",
+ degradationPreference: "maintain-resolution",
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
});
});
@@ -362,27 +364,29 @@ describe("HostPeer source replacement", () => {
});
it("continues queued profile updates after initial configuration rejects", async () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
- try {
- const peer = createPeer(
- createStream(
- createTrack("video", "video"),
- createTrack("audio", "audio"),
- ),
- );
+ const updates: PeerSnapshot[] = [];
+ const peer = createPeer(
+ createStream(
+ createTrack("video", "video"),
+ createTrack("audio", "audio"),
+ ),
+ (snapshot) => updates.push(snapshot),
+ );
- const starting = peer.start();
- const videoSender = FakePeerConnection.latest!.senders[0]!;
- videoSender.failNextSetParameters = true;
- const updating = peer.updateProfile(QUALITY_PROFILES["1080p60"]);
+ const starting = peer.start();
+ const videoSender = FakePeerConnection.latest!.senders[0]!;
+ videoSender.failNextSetParameters = true;
+ const updating = peer.updateProfile(QUALITY_PROFILES["1080p60"]);
- await expect(starting).resolves.toBe(true);
- await expect(updating).resolves.toBe(true);
- expect(videoSender.setParameters).toHaveBeenCalledTimes(2);
- expect(videoSender.appliedMaxBitrates).toEqual([8_000_000]);
- } finally {
- warn.mockRestore();
- }
+ await expect(starting).resolves.toBe(true);
+ expect(
+ updates.some((snapshot) =>
+ snapshot.qualityWarning?.startsWith("应用发送参数失败"),
+ ),
+ ).toBe(true);
+ await expect(updating).resolves.toBe(true);
+ expect(videoSender.setParameters).toHaveBeenCalledTimes(2);
+ expect(videoSender.appliedMaxBitrates).toEqual([8_000_000]);
});
it("can retry the selected quality after a sender update fails", async () => {
@@ -406,7 +410,7 @@ describe("HostPeer source replacement", () => {
expect(videoSender.setParameters).toHaveBeenCalledTimes(3);
expect(videoSender.setParameters.mock.calls.at(-1)?.[0]).toMatchObject({
- degradationPreference: "balanced",
+ degradationPreference: "maintain-resolution",
encodings: [{ maxBitrate: 8_000_000, maxFramerate: 60 }],
});
});
@@ -484,6 +488,59 @@ describe("HostPeer source replacement", () => {
).toBe(false);
});
+ it("explains a sustained browser quality limitation without changing settings", async () => {
+ const updates: PeerSnapshot[] = [];
+ const peer = createPeer(
+ createStream(createTrack("video", "video"), null),
+ (snapshot) => updates.push(snapshot),
+ );
+ await expect(peer.start()).resolves.toBe(true);
+ const connection = FakePeerConnection.latest!;
+ const sender = connection.senders[0]!;
+ connection.statsReports.push(
+ sendStatsReport({
+ bytesSent: 1_000_000,
+ framesEncoded: 30,
+ timestamp: 1_000,
+ qualityLimitationReason: "bandwidth",
+ }),
+ sendStatsReport({
+ bytesSent: 1_500_000,
+ framesEncoded: 60,
+ timestamp: 2_000,
+ qualityLimitationReason: "bandwidth",
+ }),
+ sendStatsReport({
+ bytesSent: 2_000_000,
+ framesEncoded: 90,
+ timestamp: 3_000,
+ qualityLimitationReason: "bandwidth",
+ }),
+ sendStatsReport({
+ bytesSent: 2_500_000,
+ framesEncoded: 120,
+ timestamp: 4_000,
+ qualityLimitationReason: "none",
+ }),
+ );
+
+ const sample = async (): Promise => {
+ const updateCount = updates.length;
+ statsCallbacks[0]!();
+ await vi.waitFor(() => expect(updates.length).toBeGreaterThan(updateCount));
+ };
+ await sample();
+ await sample();
+ expect(updates.at(-1)?.qualityWarning).toBeNull();
+ await sample();
+ expect(updates.at(-1)?.qualityWarning).toContain("持续受带宽限制");
+ expect(sender.setParameters).toHaveBeenCalledOnce();
+
+ await sample();
+ expect(updates.at(-1)?.qualityWarning).toBeNull();
+ expect(sender.setParameters).toHaveBeenCalledOnce();
+ });
+
it("rolls the first sender back when the second replacement fails", async () => {
const oldVideo = createTrack("video", "old-video");
const oldAudio = createTrack("audio", "old-audio");
@@ -770,6 +827,12 @@ describe("ViewerRelay downstream ownership", () => {
});
it("applies the latest profile to the current and future child", async () => {
+ const customSettings = {
+ resolution: "1440p",
+ maxFramerate: 45,
+ maxBitrate: 10_500_000,
+ degradationPreference: "balanced",
+ } as const;
const relay = new ViewerRelay(
{ iceServers: [], expiresAt: null, relayAvailable: false },
QUALITY_PROFILES["1080p60"],
@@ -785,12 +848,13 @@ describe("ViewerRelay downstream ownership", () => {
const firstConnection = FakePeerConnection.latest!;
await expect(
- relay.updateProfile(QUALITY_PROFILES["720p30"]),
+ relay.updateProfile(customSettings),
).resolves.toBe(true);
expect(
firstConnection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
).toMatchObject({
- encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
+ degradationPreference: "balanced",
+ encodings: [{ maxBitrate: 10_500_000, maxFramerate: 45 }],
});
relay.setChild("second-profile-child");
@@ -804,7 +868,8 @@ describe("ViewerRelay downstream ownership", () => {
expect(
secondConnection.senders[0]?.setParameters.mock.calls[0]?.[0],
).toMatchObject({
- encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
+ degradationPreference: "balanced",
+ encodings: [{ maxBitrate: 10_500_000, maxFramerate: 45 }],
});
});
});
diff --git a/tests/peer-assisted-benchmark.test.ts b/tests/peer-assisted-benchmark.test.ts
index beb6071..6074bc8 100644
--- a/tests/peer-assisted-benchmark.test.ts
+++ b/tests/peer-assisted-benchmark.test.ts
@@ -8,6 +8,13 @@ import {
summarizeSamples,
} from "../scripts/peer-assisted-benchmark";
+const lowQualitySettings = {
+ resolution: "720p",
+ maxFramerate: 30,
+ maxBitrate: 3_000_000,
+ degradationPreference: "maintain-resolution",
+} as const;
+
function page(
role: "host" | "viewer",
label: string,
@@ -23,7 +30,8 @@ function page(
authenticatedAtEpochMs: 1_000,
authenticateSentAtEpochMs: 900,
signalingConnected: true,
- qualityProfileId: "720p30",
+ qualitySettings: lowQualitySettings,
+ senderParameterApplications: [],
maxActiveOutboundMediaEdges: sendEdges,
maxAssignedChildren: sendEdges,
assignment: {
@@ -87,6 +95,23 @@ describe("peer-assisted benchmark configuration", () => {
).toThrow(/selected viewer count/);
});
+ it("requires a relay-sized case for the optional quality control smoke", () => {
+ expect(() =>
+ parseBenchmarkConfig({
+ CHROME_PATH: "chrome",
+ BENCHMARK_VIEWERS: "1",
+ BENCHMARK_QUALITY_SMOKE: "1",
+ }),
+ ).toThrow(/at least 3/);
+ expect(
+ parseBenchmarkConfig({
+ CHROME_PATH: "chrome",
+ BENCHMARK_VIEWERS: "3",
+ BENCHMARK_QUALITY_SMOKE: "1",
+ }).qualityControlSmoke,
+ ).toBe(true);
+ });
+
it("writes a report file by default and reserves '-' for stdout", () => {
expect(parseBenchmarkConfig({ CHROME_PATH: "chrome" }).outputPath).toBe(
"benchmark-results/peer-assisted.json",
@@ -131,7 +156,9 @@ describe("peer-assisted benchmark observations", () => {
expect(summary.maxFirstDecodedAfterAuthenticateMs).toBe(600);
expect(
summary.finalTopology.every(
- (entry) => entry.qualityProfileId === "720p30",
+ (entry) =>
+ JSON.stringify(entry.qualitySettings) ===
+ JSON.stringify(lowQualitySettings),
),
).toBe(true);
});
@@ -180,6 +207,6 @@ describe("peer-assisted benchmark observations", () => {
expect(source).toContain('"frameRate":30');
expect(source).toContain("getDisplayMedia");
expect(source).toContain("collectConnectionMetrics");
- expect(source).toContain("set-quality-profile");
+ expect(source).toContain("set-quality-settings");
});
});
diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts
index b0de179..1fa0e6b 100644
--- a/tests/protocol.test.ts
+++ b/tests/protocol.test.ts
@@ -12,6 +12,12 @@ import {
const token = "a".repeat(43);
const roomId = "123456789012";
+const qualitySettings = {
+ resolution: "1080p",
+ maxFramerate: 60,
+ maxBitrate: 8_000_000,
+ degradationPreference: "maintain-resolution",
+} as const;
describe("client signaling protocol", () => {
it("accepts a bounded authentication message", () => {
@@ -152,24 +158,36 @@ describe("client signaling protocol", () => {
);
});
- it("accepts only a bounded quality profile update", () => {
+ it("accepts only strict, bounded quality settings", () => {
expect(
clientMessageSchema.safeParse({
- type: "set-quality-profile",
- qualityProfileId: "1080p30",
+ type: "set-quality-settings",
+ qualitySettings,
}).success,
).toBe(true);
+ for (const invalid of [
+ { ...qualitySettings, resolution: "2160p" },
+ { ...qualitySettings, maxFramerate: 14 },
+ { ...qualitySettings, maxFramerate: 61 },
+ { ...qualitySettings, maxFramerate: 30.5 },
+ { ...qualitySettings, maxBitrate: 1_999_999 },
+ { ...qualitySettings, maxBitrate: 12_000_001 },
+ { ...qualitySettings, maxBitrate: 5_000_000.5 },
+ { ...qualitySettings, degradationPreference: "automatic" },
+ { ...qualitySettings, codec: "video/VP9" },
+ ]) {
+ expect(
+ clientMessageSchema.safeParse({
+ type: "set-quality-settings",
+ qualitySettings: invalid,
+ }).success,
+ ).toBe(false);
+ }
+ const { maxFramerate: _missing, ...missingField } = qualitySettings;
expect(
clientMessageSchema.safeParse({
- type: "set-quality-profile",
- qualityProfileId: "1440p60",
- }).success,
- ).toBe(false);
- expect(
- clientMessageSchema.safeParse({
- type: "set-quality-profile",
- qualityProfileId: "1080p30",
- bitrate: 5_000_000,
+ type: "set-quality-settings",
+ qualitySettings: missingField,
}).success,
).toBe(false);
});
@@ -307,7 +325,7 @@ describe("server signaling protocol", () => {
childPeerIds: ["viewer_12345678", "viewer_87654321"],
sfuPublicationGeneration: null,
},
- qualityProfileId: "1080p60",
+ qualitySettings,
};
expect(serverMessageSchema.safeParse(peerAssisted).success).toBe(true);
@@ -323,7 +341,7 @@ describe("server signaling protocol", () => {
mediaMode: "peer-assisted",
routeRevision: 0,
routeAssignment: peerAssisted.routeAssignment,
- qualityProfileId: "1080p60",
+ qualitySettings,
}).success,
).toBe(false);
expect(
@@ -351,25 +369,30 @@ describe("server signaling protocol", () => {
expect(
serverMessageSchema.safeParse({
...peerAssisted,
- qualityProfileId: "1440p60",
+ qualitySettings: { ...qualitySettings, maxBitrate: 20_000_000 },
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
...authenticatedMessage(8),
- qualityProfileId: "1080p60",
+ qualitySettings,
}).success,
).toBe(false);
expect(
serverMessageSchema.safeParse({
- type: "quality-profile",
- qualityProfileId: "720p30",
+ type: "quality-settings",
+ qualitySettings: {
+ resolution: "720p",
+ maxFramerate: 30,
+ maxBitrate: 3_000_000,
+ degradationPreference: "balanced",
+ },
}).success,
).toBe(true);
expect(
serverMessageSchema.safeParse({
- type: "quality-profile",
- qualityProfileId: "1440p60",
+ type: "quality-settings",
+ qualitySettings: { ...qualitySettings, maxFramerate: 0 },
}).success,
).toBe(false);
});
diff --git a/tests/quality.test.ts b/tests/quality.test.ts
index 9e000e3..0697e60 100644
--- a/tests/quality.test.ts
+++ b/tests/quality.test.ts
@@ -5,6 +5,7 @@ import {
captureDisplay,
configureVideoSender,
QUALITY_PROFILES,
+ senderParameterWarning,
setVideoPaused,
} from "../src/client/media/quality.ts";
@@ -13,6 +14,7 @@ function createVideoStream() {
contentHint: "",
enabled: true,
applyConstraints: vi.fn(async () => undefined),
+ getSettings: () => ({ width: 1920, height: 1080, frameRate: 60 }),
} as unknown as MediaStreamTrack;
const stream = {
getTracks: () => [videoTrack],
@@ -47,36 +49,98 @@ describe("realtime quality controls", () => {
expect(videoTrack.contentHint).toBe("motion");
});
- it("changes capture constraints without selecting the source again", async () => {
+ it("changes custom capture ceilings without selecting the source again", async () => {
const { stream, videoTrack } = createVideoStream();
- await applyCaptureProfile(stream, QUALITY_PROFILES["720p30"]);
+ await applyCaptureProfile(stream, {
+ resolution: "1440p",
+ maxFramerate: 45,
+ maxBitrate: 9_500_000,
+ degradationPreference: "balanced",
+ });
expect(videoTrack.applyConstraints).toHaveBeenCalledWith({
- width: { ideal: 1280, max: 1280 },
- height: { ideal: 720, max: 720 },
- frameRate: { ideal: 30, max: 30 },
+ width: { ideal: 2560, max: 2560 },
+ height: { ideal: 1440, max: 1440 },
+ frameRate: { ideal: 45, max: 45 },
});
});
- it("sets the screen-share sender degradation preference", async () => {
- const parameters = {
- encodings: [],
- } as unknown as RTCRtpSendParameters;
- const setParameters = vi.fn(async () => undefined);
+ it("defaults every recommended profile to clarity-first degradation", () => {
+ expect(
+ Object.values(QUALITY_PROFILES).every(
+ (profile) =>
+ profile.degradationPreference === "maintain-resolution",
+ ),
+ ).toBe(true);
+ });
+
+ it("reads back every requested sender control after setParameters", async () => {
+ let applied = { encodings: [] } as unknown as RTCRtpSendParameters;
+ const setParameters = vi.fn(async (parameters: RTCRtpSendParameters) => {
+ applied = parameters;
+ });
const sender = {
- getParameters: () => parameters,
+ track: {
+ getSettings: () => ({ width: 2560, height: 1440 }),
+ },
+ getParameters: () => applied,
setParameters,
} as unknown as RTCRtpSender;
- await configureVideoSender(sender, QUALITY_PROFILES["1080p60"]);
+ const readback = await configureVideoSender(
+ sender,
+ QUALITY_PROFILES["1080p60"],
+ );
- expect(parameters.encodings[0]).toMatchObject({
- maxBitrate: 8_000_000,
- maxFramerate: 60,
+ expect(readback).toEqual({
+ requested: {
+ maxBitrate: 8_000_000,
+ maxFramerate: 60,
+ scaleResolutionDownBy: 4 / 3,
+ degradationPreference: "maintain-resolution",
+ },
+ applied: {
+ maxBitrate: 8_000_000,
+ maxFramerate: 60,
+ scaleResolutionDownBy: 4 / 3,
+ degradationPreference: "maintain-resolution",
+ },
+ mismatches: [],
});
- expect(parameters.degradationPreference).toBe("balanced");
- expect(setParameters).toHaveBeenCalledWith(parameters);
+ expect(senderParameterWarning(readback)).toBeNull();
+ expect(setParameters).toHaveBeenCalledOnce();
+ });
+
+ it("reports fields the browser does not retain", async () => {
+ const before = { encodings: [{}] } as RTCRtpSendParameters;
+ const after = {
+ encodings: [{ maxBitrate: 8_000_000 }],
+ degradationPreference: "balanced",
+ } as RTCRtpSendParameters;
+ const getParameters = vi
+ .fn<() => RTCRtpSendParameters>()
+ .mockReturnValueOnce(before)
+ .mockReturnValue(after);
+ const sender = {
+ track: { getSettings: () => ({ width: 1920, height: 1080 }) },
+ getParameters,
+ setParameters: vi.fn(async () => undefined),
+ } as unknown as RTCRtpSender;
+
+ const readback = await configureVideoSender(
+ sender,
+ QUALITY_PROFILES["1080p60"],
+ );
+
+ expect(readback.mismatches).toEqual([
+ "maxFramerate",
+ "scaleResolutionDownBy",
+ "degradationPreference",
+ ]);
+ expect(senderParameterWarning(readback)).toContain("帧率上限");
+ expect(senderParameterWarning(readback)).toContain("分辨率缩放");
+ expect(senderParameterWarning(readback)).toContain("质量优先级");
});
it("pauses only the video track and can resume it", () => {
diff --git a/tests/server-signal.test.ts b/tests/server-signal.test.ts
index b2ae694..24f1c8b 100644
--- a/tests/server-signal.test.ts
+++ b/tests/server-signal.test.ts
@@ -21,6 +21,24 @@ import { SignalingServer } from "../src/server/signaling.ts";
import type { SfuTokenIssuer } from "../src/server/livekit-token.ts";
const allowedOrigin = "http://allowed.test";
+const defaultQualitySettings = {
+ resolution: "1080p",
+ maxFramerate: 60,
+ maxBitrate: 8_000_000,
+ degradationPreference: "maintain-resolution",
+} as const;
+const balancedQualitySettings = {
+ resolution: "1080p",
+ maxFramerate: 30,
+ maxBitrate: 5_000_000,
+ degradationPreference: "balanced",
+} as const;
+const lowQualitySettings = {
+ resolution: "720p",
+ maxFramerate: 30,
+ maxBitrate: 3_000_000,
+ degradationPreference: "maintain-resolution",
+} as const;
let runningServer: ScreenerServer | undefined;
afterEach(async () => {
@@ -674,30 +692,40 @@ describe("WebSocket signaling", () => {
expect(authenticated.hostOnline).toBe(false);
});
- it("synchronizes a bounded quality profile across a peer-assisted room", async () => {
+ it("synchronizes strict last-wins quality settings across a peer-assisted room", async () => {
const harness = await startHarness({ peerAssistedMedia: true });
const host = await openClient(harness.webSocketUrl);
const hostAuth = peerAssisted(
await authenticate(host, harness.room, "host", "quality-host"),
);
- expect(hostAuth.qualityProfileId).toBe("1080p60");
+ expect(hostAuth.qualitySettings).toEqual(defaultQualitySettings);
const viewer = await openClient(harness.webSocketUrl);
const viewerAuth = peerAssisted(
await authenticate(viewer, harness.room, "viewer", "quality-viewer"),
);
- expect(viewerAuth.qualityProfileId).toBe("1080p60");
+ expect(viewerAuth.qualitySettings).toEqual(defaultQualitySettings);
await host.inbox.next("media-assignment");
host.socket.send(
JSON.stringify({
- type: "set-quality-profile",
- qualityProfileId: "1080p30",
+ type: "set-quality-settings",
+ qualitySettings: balancedQualitySettings,
}),
);
- expect(await viewer.inbox.next("quality-profile")).toEqual({
- type: "quality-profile",
- qualityProfileId: "1080p30",
+ expect(await viewer.inbox.next("quality-settings")).toEqual({
+ type: "quality-settings",
+ qualitySettings: balancedQualitySettings,
+ });
+ host.socket.send(
+ JSON.stringify({
+ type: "set-quality-settings",
+ qualitySettings: lowQualitySettings,
+ }),
+ );
+ expect(await viewer.inbox.next("quality-settings")).toEqual({
+ type: "quality-settings",
+ qualitySettings: lowQualitySettings,
});
const lateViewer = await openClient(harness.webSocketUrl);
@@ -709,13 +737,13 @@ describe("WebSocket signaling", () => {
"quality-viewer-late",
),
);
- expect(lateViewerAuth.qualityProfileId).toBe("1080p30");
+ expect(lateViewerAuth.qualitySettings).toEqual(lowQualitySettings);
await host.inbox.next("media-assignment");
viewer.socket.send(
JSON.stringify({
- type: "set-quality-profile",
- qualityProfileId: "720p30",
+ type: "set-quality-settings",
+ qualitySettings: defaultQualitySettings,
}),
);
expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN");
@@ -805,7 +833,7 @@ describe("WebSocket signaling", () => {
"p2p-quality-host",
);
expect("mediaMode" in hostAuth).toBe(false);
- expect("qualityProfileId" in hostAuth).toBe(false);
+ expect("qualitySettings" in hostAuth).toBe(false);
const viewer = await openClient(harness.webSocketUrl);
const viewerAuth = await authenticate(
@@ -815,20 +843,20 @@ describe("WebSocket signaling", () => {
"p2p-quality-viewer",
);
expect("mediaMode" in viewerAuth).toBe(false);
- expect("qualityProfileId" in viewerAuth).toBe(false);
+ expect("qualitySettings" in viewerAuth).toBe(false);
await host.inbox.next("peer-joined");
host.socket.send(
JSON.stringify({
- type: "set-quality-profile",
- qualityProfileId: "1080p30",
+ type: "set-quality-settings",
+ qualitySettings: balancedQualitySettings,
}),
);
expect((await host.inbox.next("error")).code).toBe("FORBIDDEN");
viewer.socket.send(
JSON.stringify({
- type: "set-quality-profile",
- qualityProfileId: "720p30",
+ type: "set-quality-settings",
+ qualitySettings: lowQualitySettings,
}),
);
expect((await viewer.inbox.next("error")).code).toBe("FORBIDDEN");
@@ -2432,13 +2460,13 @@ describe("WebSocket signaling", () => {
host.socket.send(
JSON.stringify({
- type: "set-quality-profile",
- qualityProfileId: "720p30",
+ type: "set-quality-settings",
+ qualitySettings: lowQualitySettings,
}),
);
- expect(await viewer.inbox.next("quality-profile")).toEqual({
- type: "quality-profile",
- qualityProfileId: "720p30",
+ expect(await viewer.inbox.next("quality-settings")).toEqual({
+ type: "quality-settings",
+ qualitySettings: lowQualitySettings,
});
host.socket.send(
@@ -2481,7 +2509,7 @@ describe("WebSocket signaling", () => {
parentPeerId: hostAuth.peerId,
childPeerIds: [],
},
- qualityProfileId: "720p30",
+ qualitySettings: lowQualitySettings,
});
const resumedHost = await openClient(harness.webSocketUrl);
@@ -2499,7 +2527,7 @@ describe("WebSocket signaling", () => {
parentPeerId: null,
childPeerIds: [viewerAuth.peerId],
},
- qualityProfileId: "720p30",
+ qualitySettings: lowQualitySettings,
});
expect(await reconnectedViewer.inbox.next("host-status")).toMatchObject({
online: true,
diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts
index c78c34d..a837fa0 100644
--- a/tests/sfu-client.test.ts
+++ b/tests/sfu-client.test.ts
@@ -182,11 +182,10 @@ const connection = {
};
const qualityProfile = {
- label: "1080p 60",
- width: 1920,
- height: 1080,
- frameRate: 60,
+ resolution: "1080p",
+ maxFramerate: 60,
maxBitrate: 8_000_000,
+ degradationPreference: "maintain-resolution",
} as const;
function track(kind: "video" | "audio", id: string): MediaStreamTrack {
@@ -254,7 +253,7 @@ describe("SfuPublisher", () => {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
- degradationPreference: "balanced",
+ degradationPreference: "maintain-resolution",
});
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
source: Track.Source.ScreenShareAudio,
@@ -275,11 +274,10 @@ describe("SfuPublisher", () => {
await expect(
publisher.updateProfile({
- label: "720p 30",
- width: 1280,
- height: 720,
- frameRate: 30,
+ resolution: "720p",
+ maxFramerate: 30,
maxBitrate: 3_000_000,
+ degradationPreference: "balanced",
}),
).resolves.toBe(true);
@@ -295,6 +293,57 @@ describe("SfuPublisher", () => {
}),
);
expect(room.localParticipant.publishTrack).toHaveBeenCalledOnce();
+ expect(publisher.getQualityWarning()).toBeNull();
+ });
+
+ it("retains a visible warning when the SFU sender rewrites a parameter", async () => {
+ const publisher = new SfuPublisher();
+ await publisher.connect(connection);
+ await publisher.activate(stream(track("video", "video-1")), qualityProfile);
+ const sender = livekit.state.rooms[0].localParticipant.publications[0].track
+ .sender;
+ sender.setParameters.mockImplementationOnce(async (parameters) => {
+ sender.parameters = {
+ ...parameters,
+ encodings: parameters.encodings.map((encoding) => ({
+ ...encoding,
+ maxBitrate: 2_000_000,
+ })),
+ };
+ });
+
+ await expect(
+ publisher.updateProfile({
+ resolution: "1080p",
+ maxFramerate: 30,
+ maxBitrate: 5_000_000,
+ degradationPreference: "balanced",
+ }),
+ ).resolves.toBe(true);
+
+ expect(publisher.getQualityWarning()).toContain("码率上限");
+ });
+
+ it("retains a visible warning when SFU sender parameters are rejected", async () => {
+ const publisher = new SfuPublisher();
+ await publisher.connect(connection);
+ await publisher.activate(stream(track("video", "video-1")), qualityProfile);
+ const sender = livekit.state.rooms[0].localParticipant.publications[0].track
+ .sender;
+ sender.setParameters.mockRejectedValueOnce(new Error("unsupported"));
+
+ await expect(
+ publisher.updateProfile({
+ resolution: "720p",
+ maxFramerate: 30,
+ maxBitrate: 3_000_000,
+ degradationPreference: "balanced",
+ }),
+ ).resolves.toBe(false);
+
+ expect(publisher.getQualityWarning()).toBe(
+ "应用 SFU 发送参数失败:unsupported",
+ );
});
it("restores the previous video after a partially applied replacement fails", async () => {
From c8bc2fbee1dc33005fb38166dc0a6379f5acc98d Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:42:10 +0800
Subject: [PATCH 4/9] test(media): record clarity control evidence
---
docs/project-memory.md | 12 ++--
docs/research/realtime-quality-adaptation.md | 61 +++++++++++---------
docs/status.md | 15 +++--
docs/方案设计.md | 2 +
scripts/peer-assisted-benchmark.ts | 6 +-
5 files changed, 53 insertions(+), 43 deletions(-)
diff --git a/docs/project-memory.md b/docs/project-memory.md
index e72394d..59a9372 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -24,7 +24,7 @@ Last updated: 2026-08-19
- Keep user-operated mini-SFU deployment and central single-node SFU Draft PR #12 as optional infrastructure. When configured, the route controller may select them automatically as the last fallback; their fanout egress belongs to their operator and does not justify Redis or multi-node infrastructure.
- Keep automatic routing behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make and never exceeds two active edges, and a binary per-session relay capability keeps detected mobile/iPad clients as leaves. Corrected local recovery passes functionally but misses the sub-second gate.
- Prefer direct UDP, then TURN/UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses TCP 5349 by default; TCP 443 needs a dedicated address or validated L4/SNI routing.
-- Treat 3/5/8 Mbps profiles as ceilings. Production users report severe degradation across all three; run one controlled 1/2/3-viewer capture before changing constants, then A/B clarity-first against `balanced`. Do not hide actual limits or add an automatic score/controller without evidence.
+- Treat every quality setting as a ceiling. The current Draft defaults to clarity-first and adds bounded manual controls plus sender readback; production remains `balanced`, and its reported degradation is still unclassified. Run the 1/2/3-viewer plus TURN matrix before changing ceilings or adding any automatic controller.
- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements prove a material gap. Keep browser codec order until target hardware proves a more efficient common codec.
- Keep room policy deployment-driven. Public and password-only deployments use random temporary rooms. A site password plus SQLite path enables sequential persistent rooms; stopping sharing leaves the room and viewer link available.
@@ -33,19 +33,19 @@ Last updated: 2026-08-19
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
-- The stacked `feat/simple-entry-diagnostics` Draft puts equal share/join actions inside the idle 16:9 stage, defaults Host/Viewer technical details closed with one native checkbox, keeps actionable warnings outside it, and uses the bounded waiting/recovery/closed copy. Chrome 151 CDP checks pass at 320/375/390 CSS px without horizontal overflow; it is not deployed.
+- The current quality stack includes Draft PR #19's equal idle-stage share/join actions and default-closed technical details; advanced quality stays in the common control area and actionable warnings stay visible. It is not deployed.
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
-- Draft PR #13 contains deterministic server/client peer-assisted assignment, standard remote-track relay, and synchronized 1080p60/1080p30/720p30 room profiles. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented.
+- Draft PR #13 contains deterministic server/client peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented.
- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU for that share. LiveKit is dormant when unconfigured.
- Corrected Chrome 151/LiveKit 1.13.5 smoke physically failed one leaf through peer recovery/reparent and two-root SFU. Frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure-to-render took 2.257 seconds. It is localhost, headless, synthetic 720p30 video only.
- Draft PR #16 (`5b09f0a`) proves one Pion RTP write fans equivalent data to two transports with independent SSRCs. It has no encoder and proves neither physical shared encode nor browser readiness.
- Chrome 151 short synthetic `1/3/5/8` runs kept host edges at two, relay edges at one, and all viewers decoding; a three-viewer relay close recovered in 5.32 seconds. This proves topology/control only, not quality, load, endurance, or silent partitions.
-- The peer-assisted authenticated snapshot and host-only update synchronize one bounded, non-persistent room profile. Online viewers receive changes; `ViewerRelay` uses the latest desired profile for current and future children, while `HostPeer` serializes sender setup, stream replacement, and last-wins profile updates. The ordinary P2P wire is unchanged. Real 1/3/5/8 quality, resource, and latency measurements remain pending.
-- Chromium 151 kept relay/sender/signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while the leaf decoded. Synthetic 640x360/30 proves control propagation, not full-resolution load.
+- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only peer-assisted updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Sender updates are serialized and read back; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action.
+- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced-to-clarity propagation, sender preference readback, stable peer-connection fingerprints, host fanout two, relay fanout one, and continued decoding. This is control evidence, not a quality, load, public-network, or endurance claim.
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
-- Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both builds, dependency audit, and repository hygiene. Production separately passed 108 tests and deployment checks.
+- The current quality/UI stack passes repository hygiene, type checking, 20 Vitest files/260 tests, and both builds. Production separately passed 108 tests and deployment checks.
## Provisional Quality Targets
diff --git a/docs/research/realtime-quality-adaptation.md b/docs/research/realtime-quality-adaptation.md
index 8cd246d..76349cf 100644
--- a/docs/research/realtime-quality-adaptation.md
+++ b/docs/research/realtime-quality-adaptation.md
@@ -6,17 +6,16 @@
## Finding
-Screener should not optimize a single quality dimension at the expense of the
-others. The previous combination of `contentHint = "motion"` and an explicit
-`degradationPreference = "maintain-framerate"` told WebRTC to preserve frame
-rate primarily by reducing resolution. That is appropriate for some fast
-motion, but it can make game UI, maps, subtitles, and text unreadable.
+Screener should not optimize one quality dimension at the expense of the
+others. `maintain-framerate` may preserve motion by reducing resolution until
+game UI, maps, subtitles, and text become unreadable; `maintain-resolution`
+may instead lower frame rate. Neither preference overrides congestion control.
-The smallest correction is to retain the game-oriented `motion` hint while
-explicitly selecting `balanced` degradation. The WebRTC API allows the user
-agent to balance frame-rate and resolution degradation; it does not prescribe
-the algorithm or guarantee a clarity-first result. Congestion control remains
-authoritative, and Chromium, Firefox, and Safari behavior must be measured.
+The current bounded implementation retains the game-oriented `motion` hint,
+defaults to `maintain-resolution`, and offers explicit balanced and fluid
+choices. This is a readability-first default, not a quality guarantee: the
+WebRTC API describes a user-agent preference rather than its algorithm, so
+Chromium, Firefox, and Safari behavior still needs measured comparison.
The inspected Chromium/libwebrtc source chain makes a screen-only shortcut
especially unsafe to assume for Screener. The JavaScript `motion` hint reaches
@@ -102,28 +101,37 @@ resolution, frame rate, or bitrate.
- A live profile change uses `track.applyConstraints()` and updates every
current sender with `RTCRtpSender.setParameters()`. It does not reopen the
source picker or renegotiate healthy peer connections.
-- The video track keeps `contentHint = "motion"`, while each sender explicitly
- uses `degradationPreference = "balanced"`. This is a user-agent preference,
- not a promise to preserve either resolution or frame rate.
+- The video track keeps `contentHint = "motion"`; recommended profiles default
+ to `maintain-resolution`, with explicit `balanced` and
+ `maintain-framerate` choices. None promises an emitted resolution or rate.
- `maxBitrate` and `maxFramerate` are ceilings. They are neither minimums nor
target guarantees, and the project does not use SDP bitrate hacks.
+- The folded advanced panel accepts only 720p/1080p/1440p, integer 15-60 fps,
+ 2-12 Mbps, and the three preferences. Audio stereo/bitrate is not exposed
+ because standard sender controls cannot reliably guarantee the negotiated
+ browser audio mode.
+- Every sender update derives from `getParameters()`, calls `setParameters()`,
+ then reads requested/applied bitrate, frame rate, scale, and preference.
+ Rejection or browser rewriting is visible rather than console-only.
+- One strict room setting is last-wins for current/future peer relays and the
+ optional SFU publisher. Ordinary P2P keeps that state local and does not add
+ it to the authenticated wire.
- Pausing the picture disables the existing video track, producing black video
without closing the room or media connection. Audio remains enabled.
-The next bounded A/B changes only degradation preference: keep `motion`, compare
-the current `balanced` behavior with `maintain-resolution`, and expose the
-result as a default clarity-first choice plus optional balanced/fluid choices.
-It may trade frame rate for readability and is not a universal fix. The
-advanced panel remains bounded to resolution ceiling, frame-rate ceiling,
-bitrate ceiling, quality priority, and audio mode; it does not expose raw SDP or
-codec internals.
+The implementation deliberately stops at manual bounded controls. It adds no
+composite score, periodic adjustment, codec forcing, SDP bitrate manipulation,
+or scene detector. Three consecutive samples of one non-`none` native
+`qualityLimitationReason` produce one explanatory warning; a reason change or
+recovery resets it and never triggers a media action.
-A Chromium 151 loopback smoke with one host, three viewers, and a synthetic
-640x360/30 source kept the same relay peer, sender, and signaling generations
-while applying 8 Mbps/60 fps, 5 Mbps/30 fps, and 3 Mbps/30 fps sender ceilings.
-The leaf continued decoding after both live switches. This verifies control
-propagation and connection preservation, not actual 1080p output, visual
-quality, CPU/GPU cost, public-network behavior, or sustained performance.
+A Chromium 151 loopback smoke with one host, three viewers, and synthetic
+720p30 video propagated balanced then clarity settings to every participant,
+displayed both sender preference readbacks, and preserved all peer-connection
+fingerprints. Host media edges peaked at two, relay edges at one, and all three
+viewers kept decoding. This verifies controls and connection preservation, not
+visual quality, full-resolution performance, CPU/GPU cost, public networks, or
+sustained behavior.
## Deliberate Non-Goals
@@ -166,6 +174,7 @@ profiles before adding an application-level adaptation controller.
- [W3C Screen Capture](https://www.w3.org/TR/screen-capture/)
- [W3C WebRTC](https://www.w3.org/TR/webrtc/)
- [W3C WebRTC Statistics](https://www.w3.org/TR/webrtc-stats/)
+- [MDN `RTCRtpSender.setParameters()`](https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters)
- [Chromium `motion` to libwebrtc `kFluid` bridge](https://chromium.googlesource.com/chromium/src/third_party/+/refs/heads/main/blink/renderer/modules/peerconnection/media_stream_video_webrtc_sink.cc)
- [libwebrtc `motion`/`kFluid` sender classification](https://webrtc.googlesource.com/src/+/3b1eab8a69cb5078befb021c5492d3f204a7d6a2/pc/rtp_sender.cc)
- [libwebrtc encoder content-type selection](https://webrtc.googlesource.com/src/+/9caef2a8b88f389af10cee841732c42a98d3d45d/media/engine/webrtc_video_engine.cc)
diff --git a/docs/status.md b/docs/status.md
index 4be93d6..4e040b3 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -21,19 +21,18 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- Direct ICE is preferred independently per media edge. Authenticated TURN/UDP and TURN/TCP are required production fallbacks; TURN/TLS is optional.
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
-- Peer-assisted profile state is bounded, memory-only, absent from ordinary P2P wire, and applied to current and future relay children.
-- The stacked `feat/simple-entry-diagnostics` Draft places equal share/join actions in the idle 16:9 stage, removes the duplicate weak entry, and defaults Host/Viewer technical details closed while keeping actionable warnings visible. It is not deployed.
+- The quality stack replaces the three-ID relay state with one strict, memory-only setting shared by current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings.
+- The same stack includes Draft PR #19's equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed.
## Verified Evidence
- Production passed 108 tests, both builds, public HTTPS/access/TURN checks, clean activation, and a database restart retaining room `1`.
- Draft PR #13 now provides `npm run benchmark:peer-assisted`; the full check passes type checking, 13 Vitest files with 152 tests, and both production builds.
- A short Chrome 151 synthetic `1/3/5/8` benchmark passed every topology check: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first decoded frame was about 1.05 seconds. Closing a first-level relay in the three-viewer run recovered in about 5.32 seconds without exceeding host fanout two.
-- A separate live-profile smoke kept the same relay peer, sender, and signaling generations through 8 Mbps/60, 5 Mbps/30, and 3 Mbps/30 ceilings while its leaf kept decoding.
+- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced-to-clarity propagation, visible sender preference readback, unchanged peer-connection fingerprints, host fanout two, relay fanout one, and continued decoding.
- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds.
-- Draft PR #17 passes CI, type checking, 19 Vitest files/253 tests, both production builds, dependency audit, and repository hygiene.
+- Draft PR #17 passed its 253-test baseline; the current quality/UI stack passes repository hygiene, type checking, 20 Vitest files/260 tests, and both production builds.
- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
-- The stacked UI branch passes type checking, 20 Vitest files/254 tests, both production builds, and the production dependency audit.
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
- Production HTTPS/WSS, access cookie, room/WebSocket authorization, certificate renewal, public STUN, and authenticated TURN/UDP and TURN/TCP relay-only bidirectional paths are verified. TURN/TLS is intentionally disabled.
@@ -44,7 +43,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- Android Chrome and iOS Safari remain unverified leaves. Runtime capability conservatively marks detected mobile/iPad clients as leaves; real UA/lifecycle behavior remains open.
- The observed recovery starts from a page close immediately seen by the server. A silent partition can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; it remains unverified.
- Real screen/game audio, heterogeneous machines and networks, mobile lifecycle behavior, the production live quality/pause cycle, room `1` stop-and-republish/link reuse, and sustained profile performance remain unverified.
-- Production users report severe resolution/bitrate/FPS degradation across all profiles. A controlled 1/2/3-viewer and TURN sample must distinguish capture, per-edge CPU, bandwidth/path, and receiver limits before changing ceilings or codecs; clarity-first versus `balanced` is the first bounded A/B.
+- Production users report severe resolution/bitrate/FPS degradation across all profiles. The Draft now permits bounded clarity/balanced comparison, but a controlled 1/2/3-viewer and TURN sample must still distinguish capture, per-edge CPU, path, and receiver limits before any quality claim or automatic controller.
- Browser relays do not share encoding. Encoded objects, custom congestion control, multiple trees, and network coding remain separate measured candidates.
- The long-term endpoint budget is at most two downstream edges for both the host and relay-capable viewers, with one compatible encoded output reused across both edges where a native media engine can prove it. The current browser spike remains host capacity two/viewer capacity one and performs a new encode at each relay. Packet/layer striping and multi-parent assembly are recorded, not implemented.
- The corrected smoke is localhost/headless/video-only; public transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
@@ -52,8 +51,8 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
## Next Milestone
Finish ADR-0005: address the 2.257-second fallback, then validate public
-transports, rollback, reconnect, controls, edge counts, egress, and load. Keep
-the stacked entry/diagnostics Draft separate and unmerged until its parent lands.
+transports, rollback, reconnect, controls, edge counts, egress, and load. The
+quality/UI stack remains undeployed until its parent gates pass.
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
resource, quality, latency, recovery, and browser/mobile-leaf matrix.
diff --git a/docs/方案设计.md b/docs/方案设计.md
index b80d025..14d548d 100644
--- a/docs/方案设计.md
+++ b/docs/方案设计.md
@@ -218,6 +218,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
- SQLite 房间库从 `1` 自增、重启恢复、持久房间无过期时间、数据最小化、数据库路径无全站密码时启动失败;
- TURN HMAC 固定向量、TTL、生产环境缺少 STUN 或 TURN/UDP+TCP 覆盖时启动失败,并接受可选 TURN/TLS;
- 协议非法 JSON、未知消息、错误方向、跨房间目标和 payload 限制;
+- 质量协议的 host 授权、严格字段与数值范围、last-wins、当前/未来 relay、可选 SFU 和普通 P2P wire 兼容;sender 的四字段 requested/applied 读回、拒绝与持续受限提示;
- HTTP/WS 集成:密码模式下 host/viewer gate、建房和 upgrade 只认 cookie 且无建房 Bearer 旁路;一名 host、多名 viewer、定向 offer/answer/candidate、连接上限、grace 内恢复、显式 restart/rebuild、离开和停止分享后的等待状态;
- 客户端策略与 stats 解析:会话替换不重连,本地 TURN protocol 与 ICE protocol 分离,远端 TURN transport 保持未知;
- TypeScript 类型检查、Vite production build 和仓库 hygiene。
@@ -230,6 +231,7 @@ TURN/TLS 是可选能力,不是生产启动门槛。启用时默认使用 RFC
- Windows Chrome/Edge 的音轨有/无;
- Android Chrome 与 iOS Safari 的播放手势、旋转、前后台和网络切换;
- 一名分享者、八名异构观看者连续 30 分钟,同时记录较低人数基线、实际上行、编码负载和首帧时间。真实 1:8 未完成前不得把默认上限写成性能承诺。
+- 真实桌面 Chrome 对 balanced 与 clarity 做控制传播和连接身份 smoke;该项只证明直播中应用与不重建,不代替清晰度、负载或公网矩阵。
真实矩阵未完成前,状态文档必须把这些列为待验证,而不是把设计目标写成产品能力。
diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts
index c220812..525143f 100644
--- a/scripts/peer-assisted-benchmark.ts
+++ b/scripts/peer-assisted-benchmark.ts
@@ -1245,7 +1245,7 @@ async function startHost(
await waitForPage(
cdp,
page,
- "document.querySelector('.start-button')",
+ "document.querySelector('.entry-actions button.entry-action')",
15_000,
"host controls",
signal,
@@ -1254,11 +1254,11 @@ async function startHost(
cdp,
page,
`(() => {
- const profiles = document.querySelectorAll('.segmented-control button');
+ const profiles = document.querySelectorAll('.quality-controls .segmented-control button');
const profile = profiles[${profileIndex}];
if (!(profile instanceof HTMLButtonElement)) throw new Error('Quality profile button missing');
profile.click();
- const start = document.querySelector('.start-button');
+ const start = document.querySelector('.entry-actions button.entry-action');
if (!(start instanceof HTMLButtonElement)) throw new Error('Start button missing');
start.click();
return true;
From 555d2a2d2ab102ff63605c7500011a61c91c8d9d Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:44:33 +0800
Subject: [PATCH 5/9] docs(status): record clarity controls draft
---
docs/project-memory.md | 2 +-
docs/status.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/project-memory.md b/docs/project-memory.md
index 59a9372..edf88b5 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -33,7 +33,7 @@ Last updated: 2026-08-19
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
-- The current quality stack includes Draft PR #19's equal idle-stage share/join actions and default-closed technical details; advanced quality stays in the common control area and actionable warnings stay visible. It is not deployed.
+- Draft PR #21 stacks on #19's equal idle-stage share/join actions and default-closed technical details; advanced quality stays in the common control area and actionable warnings stay visible. It is not deployed.
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
diff --git a/docs/status.md b/docs/status.md
index 4e040b3..de087af 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -21,7 +21,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- Direct ICE is preferred independently per media edge. Authenticated TURN/UDP and TURN/TCP are required production fallbacks; TURN/TLS is optional.
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
-- The quality stack replaces the three-ID relay state with one strict, memory-only setting shared by current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings.
+- Draft PR #21 replaces the three-ID relay state with one strict, memory-only setting shared by current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings.
- The same stack includes Draft PR #19's equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed.
## Verified Evidence
From e649bc636d4ac80647b0c38f5393df9529549576 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:46:16 +0800
Subject: [PATCH 6/9] docs(status): record clarity controls CI
---
docs/project-memory.md | 2 +-
docs/status.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/project-memory.md b/docs/project-memory.md
index edf88b5..f4dbf66 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -45,7 +45,7 @@ Last updated: 2026-08-19
- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only peer-assisted updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Sender updates are serialized and read back; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action.
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced-to-clarity propagation, sender preference readback, stable peer-connection fingerprints, host fanout two, relay fanout one, and continued decoding. This is control evidence, not a quality, load, public-network, or endurance claim.
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
-- The current quality/UI stack passes repository hygiene, type checking, 20 Vitest files/260 tests, and both builds. Production separately passed 108 tests and deployment checks.
+- Draft PR #21 passes CI, repository hygiene, type checking, 20 Vitest files/260 tests, and both builds. Production separately passed 108 tests and deployment checks.
## Provisional Quality Targets
diff --git a/docs/status.md b/docs/status.md
index de087af..837c3e8 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -31,7 +31,7 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- A short Chrome 151 synthetic `1/3/5/8` benchmark passed every topology check: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first decoded frame was about 1.05 seconds. Closing a first-level relay in the three-viewer run recovered in about 5.32 seconds without exceeding host fanout two.
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced-to-clarity propagation, visible sender preference readback, unchanged peer-connection fingerprints, host fanout two, relay fanout one, and continued decoding.
- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds.
-- Draft PR #17 passed its 253-test baseline; the current quality/UI stack passes repository hygiene, type checking, 20 Vitest files/260 tests, and both production builds.
+- Draft PR #17 passed its 253-test baseline; Draft PR #21 passes CI, repository hygiene, type checking, 20 Vitest files/260 tests, and both production builds.
- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
From 5bffd5a9f6bf7830c2bc78ffe841d8a8a8210d77 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:24:23 +0800
Subject: [PATCH 7/9] fix(media): complete quality control verification
---
scripts/peer-assisted-benchmark.ts | 153 +++++++++++++++++++++-----
src/client/media/host-sfu-route.ts | 12 +-
src/client/pages/HostPage.tsx | 21 ++--
src/client/sfu/publisher.ts | 44 +++++++-
tests/peer-assisted-benchmark.test.ts | 25 ++++-
tests/sfu-client.test.ts | 78 +++++++++++++
6 files changed, 292 insertions(+), 41 deletions(-)
diff --git a/scripts/peer-assisted-benchmark.ts b/scripts/peer-assisted-benchmark.ts
index 525143f..ab1be0f 100644
--- a/scripts/peer-assisted-benchmark.ts
+++ b/scripts/peer-assisted-benchmark.ts
@@ -14,6 +14,7 @@ import { loadConfig } from "../src/server/config";
import {
QUALITY_PROFILES,
QUALITY_RESOLUTIONS,
+ qualitySettingsEqual,
type QualityProfileId,
type QualitySettings,
} from "../src/client/media/quality";
@@ -80,6 +81,7 @@ interface PageObservation {
maxAssignedChildren: number;
firstDecodedAtEpochMs: number | null;
firstRenderedAtEpochMs: number | null;
+ renderedFrames: number;
connections: ConnectionObservation[];
}
@@ -677,6 +679,7 @@ export function buildBenchmarkInitScript(options: {
maxAssignedChildren: 0,
firstDecodedAtEpochMs: null,
firstRenderedAtEpochMs: null,
+ renderedFrames: 0,
};
const connections = [];
const descriptions = [];
@@ -859,6 +862,7 @@ export function buildBenchmarkInitScript(options: {
const onFrame = (now) => {
const at = performance.timeOrigin + now;
if (state.firstRenderedAtEpochMs === null) state.firstRenderedAtEpochMs = at;
+ state.renderedFrames += 1;
video.requestVideoFrameCallback(onFrame);
};
video.requestVideoFrameCallback(onFrame);
@@ -1382,7 +1386,10 @@ async function runQualityControlSmoke(
initialSettings: QualitySettings,
signal: AbortSignal,
): Promise<{
+ settingsPropagated: boolean;
peerConnectionsStable: boolean;
+ senderReadbacksMatched: boolean;
+ viewersAdvanced: boolean;
}> {
const hostPage = pages[0];
if (!hostPage) {
@@ -1395,7 +1402,7 @@ async function runQualityControlSmoke(
degradationPreference: "balanced",
} as const satisfies QualitySettings;
- const waitForSettings = async (settings: QualitySettings): Promise => {
+ const waitForSettings = async (settings: QualitySettings): Promise => {
const serializedSettings = JSON.stringify(JSON.stringify(settings));
await Promise.all(
pages.map((page) =>
@@ -1409,13 +1416,21 @@ async function runQualityControlSmoke(
),
),
);
+ const snapshots = await Promise.all(
+ pages.map((page) => quickSnapshot(cdp, page)),
+ );
+ return snapshots.every(
+ (page) =>
+ page.qualitySettings !== null &&
+ qualitySettingsEqual(page.qualitySettings, settings),
+ );
};
- const sendingLabels = new Set(
- baseline
- .filter((page) => page.assignment.childPeerIds.length > 0)
- .map((page) => page.label),
+ const activeSenderCounts = new Map(
+ baseline.map((page) => [page.label, activeVideoEdgeCount(page, "send")]),
+ );
+ const sendingPages = pages.filter(
+ (page) => (activeSenderCounts.get(page.label) ?? 0) > 0,
);
- const sendingPages = pages.filter((page) => sendingLabels.has(page.label));
await Promise.all(
sendingPages.map((page) =>
evaluate(
@@ -1430,37 +1445,94 @@ async function runQualityControlSmoke(
),
),
);
- const waitForReadback = async (label: "平衡" | "清晰"): Promise => {
- const expected = JSON.stringify(`${label} / ${label}`);
- await Promise.all(
- sendingPages.map((page) =>
- waitForPage(
+ const waitForReadback = async (
+ label: "平衡" | "清晰",
+ ): Promise => {
+ const matched = await Promise.all(
+ sendingPages.map(async (page) => {
+ const expectedCount = activeSenderCounts.get(page.label) ?? 0;
+ const expected = JSON.stringify(`${label} / ${label}`);
+ const predicate = `(() => {
+ const values = Array.from(document.querySelectorAll('.metric'))
+ .filter((metric) => metric.querySelector('dt')?.textContent?.trim() === '请求 / 应用优先级')
+ .map((metric) => metric.querySelector('dd')?.textContent?.trim());
+ return values.length === ${expectedCount} && values.every((value) => value === ${expected});
+ })()`;
+ await waitForPage(
cdp,
page,
- `Array.from(document.querySelectorAll('.metric')).some((metric) =>
- metric.querySelector('dt')?.textContent?.trim() === '请求 / 应用优先级' &&
- metric.querySelector('dd')?.textContent?.trim() === ${expected}
- )`,
+ predicate,
10_000,
`${label} sender parameter readback`,
signal,
- ),
- ),
+ );
+ return evaluate(cdp, page, `Boolean(${predicate})`);
+ }),
+ );
+ return (
+ sendingPages.length > 0 &&
+ matched.length === sendingPages.length &&
+ matched.every(Boolean)
);
};
+ const viewerPages = pages.slice(1);
+ const waitForViewerProgress = async (): Promise => {
+ for (const page of viewerPages) {
+ await cdp.call("Page.bringToFront", {}, page.sessionId);
+ const before = [await progressSample(cdp, page)];
+ const deadline = Date.now() + 10_000;
+ let advanced = false;
+ while (Date.now() < deadline) {
+ const after = [await progressSample(cdp, page)];
+ if (everyViewerAdvanced(before, after)) {
+ advanced = true;
+ break;
+ }
+ await delay(100, signal);
+ }
+ if (!advanced) {
+ return false;
+ }
+ }
+ return viewerPages.length > 0;
+ };
- await applyQualityPreference(cdp, hostPage, "平衡", signal);
- await waitForSettings(balanced);
- await waitForReadback("平衡");
- await applyQualityPreference(cdp, hostPage, "清晰优先", signal);
- await waitForSettings(initialSettings);
- await waitForReadback("清晰");
+ const runStep = async (
+ label: "平衡" | "清晰优先",
+ readbackLabel: "平衡" | "清晰",
+ settings: QualitySettings,
+ ): Promise<{
+ settingsPropagated: boolean;
+ senderReadbacksMatched: boolean;
+ viewersAdvanced: boolean;
+ }> => {
+ await cdp.call("Page.bringToFront", {}, hostPage.sessionId);
+ await applyQualityPreference(cdp, hostPage, label, signal);
+ const settingsPropagated = await waitForSettings(settings);
+ const senderReadbacksMatched = await waitForReadback(readbackLabel);
+ const viewersAdvanced = await waitForViewerProgress();
+ return {
+ settingsPropagated,
+ senderReadbacksMatched,
+ viewersAdvanced,
+ };
+ };
+
+ const balancedResult = await runStep("平衡", "平衡", balanced);
+ const clarityResult = await runStep("清晰优先", "清晰", initialSettings);
const final = await Promise.all(pages.map((page) => quickSnapshot(cdp, page)));
return {
+ settingsPropagated:
+ balancedResult.settingsPropagated && clarityResult.settingsPropagated,
peerConnectionsStable:
peerConnectionFingerprint(final) === baselineFingerprint,
+ senderReadbacksMatched:
+ balancedResult.senderReadbacksMatched &&
+ clarityResult.senderReadbacksMatched,
+ viewersAdvanced:
+ balancedResult.viewersAdvanced && clarityResult.viewersAdvanced,
};
}
@@ -1471,6 +1543,25 @@ function totalDecodedFrames(page: PageObservation): number {
);
}
+export function everyViewerAdvanced(
+ before: PageObservation[],
+ after: PageObservation[],
+): boolean {
+ const baselineViewers = before.filter((page) => page.role === "viewer");
+ const currentByLabel = new Map(after.map((page) => [page.label, page]));
+ return (
+ baselineViewers.length > 0 &&
+ baselineViewers.every((baseline) => {
+ const current = currentByLabel.get(baseline.label);
+ return (
+ current !== undefined &&
+ totalDecodedFrames(current) > totalDecodedFrames(baseline) &&
+ current.renderedFrames > baseline.renderedFrames
+ );
+ })
+ );
+}
+
function descendantsOf(
rootPeerId: string,
pages: PageObservation[],
@@ -1677,8 +1768,8 @@ async function runCase(
);
checks.push({
name: "quality-control-propagation",
- passed: true,
- actual: true,
+ passed: qualityControl.settingsPropagated,
+ actual: qualityControl.settingsPropagated,
expected: "balanced and clarity settings reach every participant",
});
checks.push({
@@ -1689,9 +1780,15 @@ async function runCase(
});
checks.push({
name: "quality-control-sender-readback",
- passed: true,
- actual: true,
- expected: "host and active relay display both sender preference readbacks",
+ passed: qualityControl.senderReadbacksMatched,
+ actual: qualityControl.senderReadbacksMatched,
+ expected: "every baseline active video sender displays matching preference readback",
+ });
+ checks.push({
+ name: "quality-control-viewer-progress",
+ passed: qualityControl.viewersAdvanced,
+ actual: qualityControl.viewersAdvanced,
+ expected: "every viewer decodes and renders frames after both setting changes",
});
}
if (config.recoveryViewerCount === viewerCount) {
diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts
index 02dc763..c76a154 100644
--- a/src/client/media/host-sfu-route.ts
+++ b/src/client/media/host-sfu-route.ts
@@ -5,7 +5,10 @@ import type {
ServerMessage,
} from "../../shared/protocol";
import { SfuPublisher, type SfuConnectionConfig } from "../sfu/publisher";
-import type { QualityProfile } from "./quality";
+import type {
+ QualityProfile,
+ VideoSenderParameterReadback,
+} from "./quality";
import {
MediaRouteTransition,
type RouteOperationToken,
@@ -20,6 +23,7 @@ interface HostPublisherTransport {
replaceStream(stream: MediaStream): Promise;
updateProfile(profile: QualityProfile): Promise;
getQualityWarning?(): string | null;
+ getSenderParameters?(): VideoSenderParameterReadback | null;
disconnect(): Promise;
}
@@ -224,6 +228,12 @@ export class HostSfuRoute {
: null;
}
+ getSenderParameters(): VideoSenderParameterReadback | null {
+ return this.active?.active
+ ? (this.active.publisher.getSenderParameters?.() ?? null)
+ : null;
+ }
+
replaceStream(stream: MediaStream): Promise {
return this.active
? this.active.publisher.replaceStream(stream).catch(() => false)
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index 9f6e44e..6b9b470 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -371,12 +371,12 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
commitQuality(nextProfile);
setDetails(captureDetails(activeStream));
- if (peerAssistedRef.current) {
+ const roomSettingsSent =
+ !peerAssistedRef.current ||
signalRef.current?.send({
type: "set-quality-settings",
qualitySettings: nextProfile,
- });
- }
+ }) === true;
const activeSfuRoute = hostSfuRouteRef.current;
const [results, sfuUpdated] = await Promise.all([
Promise.all(
@@ -396,11 +396,18 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
hostSfuRouteRef.current === activeSfuRoute
? (activeSfuRoute?.getQualityWarning() ?? null)
: null;
+ const connectionWarning =
+ failed > 0 || !sfuUpdated
+ ? "画质已切换,但部分观看连接未能应用新参数"
+ : null;
+ const syncWarning = roomSettingsSent
+ ? null
+ : "房间画质同步将在信令重连后继续";
+ const warning = [sfuWarning ?? connectionWarning, syncWarning]
+ .filter((message): message is string => message !== null)
+ .join(";");
setNotice(
- sfuWarning ??
- (failed > 0 || !sfuUpdated
- ? "画质已切换,但部分观看连接未能应用新参数"
- : `画质已切换为 ${qualitySettingsLabel(nextProfile)}`),
+ warning || `画质已切换为 ${qualitySettingsLabel(nextProfile)}`,
);
}
} catch (error) {
diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts
index 5773272..42204db 100644
--- a/src/client/sfu/publisher.ts
+++ b/src/client/sfu/publisher.ts
@@ -42,6 +42,7 @@ export class SfuPublisher {
private video: PublishedTrack | null = null;
private audio: PublishedTrack | null = null;
private profile: QualityProfile | null = null;
+ private senderParameters: VideoSenderParameterReadback | null = null;
private qualityWarning: string | null = null;
private state: PublisherState = "idle";
private generation = 0;
@@ -122,6 +123,10 @@ export class SfuPublisher {
if (!this.owns(room, generation)) {
return false;
}
+ const senderParameters = await configurePublishedVideo(video, profile);
+ if (!this.owns(room, generation)) {
+ return false;
+ }
let audio: PublishedTrack | null = null;
if (audioTrack) {
@@ -139,7 +144,7 @@ export class SfuPublisher {
this.video = video;
this.audio = audio;
this.profile = profile;
- this.qualityWarning = null;
+ this.retainSenderParameters(senderParameters);
this.state = "active";
return true;
} catch (error) {
@@ -187,6 +192,7 @@ export class SfuPublisher {
this.video = null;
this.audio = null;
this.profile = null;
+ this.senderParameters = null;
this.qualityWarning = null;
this.state = "prepared";
return true;
@@ -202,7 +208,8 @@ export class SfuPublisher {
}
const sdk = this.sdk;
const previousVideo = this.video;
- if (!sdk || !previousVideo) {
+ const profile = this.profile;
+ if (!sdk || !previousVideo || !profile) {
throw new Error("SFU publisher has no active video publication");
}
@@ -244,10 +251,18 @@ export class SfuPublisher {
this.audio = nextAudio;
}
+ const senderParameters = await configurePublishedVideo(
+ previousVideo,
+ profile,
+ );
+ if (!this.owns(room, generation)) {
+ return false;
+ }
previousVideo.rawTrack = nextVideoTrack;
if (previousAudio && nextAudioTrack) {
previousAudio.rawTrack = nextAudioTrack;
}
+ this.retainSenderParameters(senderParameters);
return true;
} catch (error) {
if (!this.owns(room, generation)) {
@@ -276,6 +291,14 @@ export class SfuPublisher {
if (!this.owns(room, generation)) {
return false;
}
+ const senderParameters = await configurePublishedVideo(
+ previousVideo,
+ profile,
+ );
+ if (!this.owns(room, generation)) {
+ return false;
+ }
+ this.retainSenderParameters(senderParameters);
}
return false;
} catch (rollbackError) {
@@ -307,7 +330,7 @@ export class SfuPublisher {
return false;
}
this.profile = profile;
- this.qualityWarning = senderParameterWarning(readback);
+ this.retainSenderParameters(readback);
return true;
} catch (error) {
if (!this.owns(room, generation)) {
@@ -318,10 +341,11 @@ export class SfuPublisher {
? `应用 SFU 发送参数失败:${error.message}`
: "应用 SFU 发送参数失败";
try {
- await configurePublishedVideo(video, previousProfile);
+ const readback = await configurePublishedVideo(video, previousProfile);
if (!this.owns(room, generation)) {
return false;
}
+ this.senderParameters = readback;
this.qualityWarning = failureWarning;
return false;
} catch (rollbackError) {
@@ -338,6 +362,10 @@ export class SfuPublisher {
return this.qualityWarning;
}
+ getSenderParameters(): VideoSenderParameterReadback | null {
+ return this.senderParameters;
+ }
+
async disconnect(): Promise {
const room = this.invalidate();
if (room) {
@@ -393,6 +421,7 @@ export class SfuPublisher {
this.video = null;
this.audio = null;
this.profile = null;
+ this.senderParameters = null;
this.qualityWarning = null;
return room;
}
@@ -413,6 +442,13 @@ export class SfuPublisher {
this.terminalNotified = true;
this.events.onDisconnected?.();
}
+
+ private retainSenderParameters(
+ readback: VideoSenderParameterReadback,
+ ): void {
+ this.senderParameters = readback;
+ this.qualityWarning = senderParameterWarning(readback);
+ }
}
async function publishTrack(
diff --git a/tests/peer-assisted-benchmark.test.ts b/tests/peer-assisted-benchmark.test.ts
index 6074bc8..8e0b36f 100644
--- a/tests/peer-assisted-benchmark.test.ts
+++ b/tests/peer-assisted-benchmark.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
activeVideoEdgeCount,
buildBenchmarkInitScript,
+ everyViewerAdvanced,
parseBenchmarkConfig,
parseViewerCounts,
summarizeSamples,
@@ -31,7 +32,6 @@ function page(
authenticateSentAtEpochMs: 900,
signalingConnected: true,
qualitySettings: lowQualitySettings,
- senderParameterApplications: [],
maxActiveOutboundMediaEdges: sendEdges,
maxAssignedChildren: sendEdges,
assignment: {
@@ -40,6 +40,7 @@ function page(
},
firstDecodedAtEpochMs: role === "viewer" ? 1_500 : null,
firstRenderedAtEpochMs: role === "viewer" ? 1_550 : null,
+ renderedFrames: role === "viewer" ? 10 : 0,
connections: [
...Array.from({ length: sendEdges }, (_, index) => ({
index,
@@ -193,6 +194,27 @@ describe("peer-assisted benchmark observations", () => {
).toBe(false);
});
+ it("requires every viewer to decode and render after a quality change", () => {
+ const before = [
+ page("host", "host", 2, 0),
+ page("viewer", "viewer-1", 1, 1),
+ page("viewer", "viewer-2", 0, 1),
+ ];
+ const after = structuredClone(before);
+ for (const viewer of after.filter((entry) => entry.role === "viewer")) {
+ viewer.connections.at(-1)!.receiveTotals!.framesTotal += 1;
+ viewer.renderedFrames += 1;
+ }
+ expect(everyViewerAdvanced(before, after)).toBe(true);
+
+ after[2]!.renderedFrames = before[2]!.renderedFrames;
+ expect(everyViewerAdvanced(before, after)).toBe(false);
+ after[2]!.renderedFrames += 1;
+ after[1]!.connections.at(-1)!.receiveTotals!.framesTotal =
+ before[1]!.connections.at(-1)!.receiveTotals!.framesTotal;
+ expect(everyViewerAdvanced(before, after)).toBe(false);
+ });
+
it("injects the requested deterministic capture dimensions", () => {
const source = buildBenchmarkInitScript({
label: "host-1",
@@ -208,5 +230,6 @@ describe("peer-assisted benchmark observations", () => {
expect(source).toContain("getDisplayMedia");
expect(source).toContain("collectConnectionMetrics");
expect(source).toContain("set-quality-settings");
+ expect(source).toContain("renderedFrames");
});
});
diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts
index a837fa0..d9f45b7 100644
--- a/tests/sfu-client.test.ts
+++ b/tests/sfu-client.test.ts
@@ -18,6 +18,11 @@ const livekit = vi.hoisted(() => {
readonly getParameters = vi.fn(() => this.parameters);
readonly setParameters = vi.fn(
async (parameters: RTCRtpSendParameters): Promise => {
+ const failure = state.nextSenderParameterError;
+ if (failure) {
+ state.nextSenderParameterError = null;
+ throw failure;
+ }
this.parameters = parameters;
},
);
@@ -124,9 +129,11 @@ const livekit = vi.hoisted(() => {
const state: {
rooms: FakeRoom[];
connectGate: Promise | null;
+ nextSenderParameterError: Error | null;
} = {
rooms: [],
connectGate: null,
+ nextSenderParameterError: null,
};
return {
@@ -210,6 +217,7 @@ function deferred(): {
beforeEach(() => {
livekit.state.rooms.length = 0;
livekit.state.connectGate = null;
+ livekit.state.nextSenderParameterError = null;
vi.stubGlobal("MediaStream", FakeMediaStream);
});
@@ -246,6 +254,7 @@ describe("SfuPublisher", () => {
).resolves.toBe(true);
const room = livekit.state.rooms[0];
+ const sender = room.localParticipant.publications[0].track.sender;
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, video, {
source: Track.Source.ScreenShare,
simulcast: false,
@@ -259,12 +268,43 @@ describe("SfuPublisher", () => {
source: Track.Source.ScreenShareAudio,
dtx: false,
});
+ expect(sender.setParameters).toHaveBeenCalledOnce();
+ expect(publisher.getSenderParameters()).toEqual({
+ requested: {
+ maxBitrate: 8_000_000,
+ maxFramerate: 60,
+ scaleResolutionDownBy: 1,
+ degradationPreference: "maintain-resolution",
+ },
+ applied: {
+ maxBitrate: 8_000_000,
+ maxFramerate: 60,
+ scaleResolutionDownBy: 1,
+ degradationPreference: "maintain-resolution",
+ },
+ mismatches: [],
+ });
await expect(publisher.deactivate()).resolves.toBe(true);
expect(room.localParticipant.unpublishTrack).toHaveBeenCalledTimes(2);
expect(room.disconnect).not.toHaveBeenCalled();
});
+ it("fails closed when initial sender configuration is rejected", async () => {
+ const disconnected = vi.fn();
+ const publisher = new SfuPublisher({ onDisconnected: disconnected });
+ await publisher.connect(connection);
+ livekit.state.nextSenderParameterError = new Error("parameters rejected");
+
+ await expect(
+ publisher.activate(stream(track("video", "video-1")), qualityProfile),
+ ).rejects.toThrow("parameters rejected");
+
+ expect(livekit.state.rooms[0].disconnect).toHaveBeenCalledWith(false);
+ expect(disconnected).toHaveBeenCalledOnce();
+ expect(publisher.getSenderParameters()).toBeNull();
+ });
+
it("updates the active sender profile without republishing", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
@@ -365,6 +405,44 @@ describe("SfuPublisher", () => {
expect(localTrack.currentTrack).toBe(previousVideo);
});
+ it("reapplies and retains the current sender settings after replacing video", async () => {
+ const publisher = new SfuPublisher();
+ await publisher.connect(connection);
+ await publisher.activate(stream(track("video", "video-1")), qualityProfile);
+ const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
+
+ await expect(
+ publisher.replaceStream(stream(track("video", "video-2"))),
+ ).resolves.toBe(true);
+
+ expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(2);
+ expect(publisher.getSenderParameters()?.requested).toMatchObject({
+ maxBitrate: 8_000_000,
+ maxFramerate: 60,
+ degradationPreference: "maintain-resolution",
+ });
+ });
+
+ it("restores sender settings when replacement configuration is rejected", async () => {
+ const previousVideo = track("video", "video-1");
+ const publisher = new SfuPublisher();
+ await publisher.connect(connection);
+ await publisher.activate(stream(previousVideo), qualityProfile);
+ const localTrack = livekit.state.rooms[0].localParticipant.publications[0].track;
+ localTrack.sender.setParameters.mockRejectedValueOnce(
+ new Error("replacement parameters rejected"),
+ );
+
+ await expect(
+ publisher.replaceStream(stream(track("video", "video-2"))),
+ ).resolves.toBe(false);
+
+ expect(localTrack.replaceTrack).toHaveBeenCalledTimes(2);
+ expect(localTrack.currentTrack).toBe(previousVideo);
+ expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(3);
+ expect(publisher.getSenderParameters()?.applied.maxBitrate).toBe(8_000_000);
+ });
+
it("disconnects fail-closed when replacement rollback also fails", async () => {
const disconnected = vi.fn();
const previousVideo = track("video", "video-1");
From d43d7b5eb3c2dc637f69cf6c165e64f21ee5d540 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:34:40 +0800
Subject: [PATCH 8/9] docs(media): reconcile quality control evidence
---
docs/README.md | 2 +-
docs/adr/0001-p2p-first-media-topology.md | 10 +--
.../0004-peer-assisted-media-experiment.md | 55 +++++++++-------
.../0005-automatic-hybrid-media-routing.md | 19 +++---
docs/project-memory.md | 20 +++---
docs/research/low-server-media-routes.md | 10 +--
docs/research/peer-assisted-media.md | 64 ++++++++++++-------
docs/research/realtime-quality-adaptation.md | 15 +++--
docs/research/webrtc-p2p-screen-sharing.md | 2 +-
docs/status.md | 29 +++++----
docs/需求理解.md | 2 +-
11 files changed, 128 insertions(+), 100 deletions(-)
diff --git a/docs/README.md b/docs/README.md
index 8136fb7..87055c5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,6 +16,6 @@
- [ADR-0001](./adr/0001-p2p-first-media-topology.md): accepted P2P-first media topology and its consequences.
- [ADR-0002](./adr/0002-persistent-protected-rooms.md): optional SQLite persistence, protected sequential room IDs, and room lifecycle.
- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): proposed, experiment-only standard-WebRTC peer-assisted spike with hard fanout and failure gates.
-- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed, Draft-implemented automatic P2P/peer/SFU preparation, commit, failback, and rollback with a hard host-edge budget.
+- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): proposed, default-off automatic P2P/peer/SFU preparation, commit, failback, and rollback with a hard host-edge budget.
Documentation and project memory are part of the product source of truth. Update them in the same change that alters the corresponding requirement or architecture.
diff --git a/docs/adr/0001-p2p-first-media-topology.md b/docs/adr/0001-p2p-first-media-topology.md
index 90cfed8..8da7a81 100644
--- a/docs/adr/0001-p2p-first-media-topology.md
+++ b/docs/adr/0001-p2p-first-media-topology.md
@@ -9,10 +9,10 @@ target. Automatic, viewer-transparent routing through direct P2P,
peer-assisted media, and an enabled SFU fallback is a standing requirement and
will be governed by a later ADR.
-Draft ADR-0003 and Proposed ADR-0004 do not supersede this production baseline.
-They isolate an optional SFU implementation and a bounded peer-assisted
-experiment respectively. A later Accepted ADR is required before either changes
-the default topology.
+Historical ADR-0003's explicit whole-room SFU mode is rejected/superseded.
+Proposed ADR-0004 and ADR-0005 do not supersede this production baseline; they
+govern default-off peer-assisted and automatic optional-SFU experiments. A
+later Accepted ADR is required before either changes the production topology.
## Context
@@ -28,7 +28,7 @@ Use separate control and media planes:
- Only pairs that cannot connect directly use authenticated TURN. Production requires STUN plus TURN over UDP and TCP; TURN/TLS is an optional restrictive-network enhancement, using its standard TCP port 5349 by default. Port 443 is optional and requires a dedicated public IP or a validated layer-4/SNI route when HTTPS already owns that address and port.
- Candidate selection is independent per pair. A room may simultaneously contain direct and relayed viewers without moving working peers onto the server.
- The deployed MVP broadcaster creates one peer connection per viewer. Rooms default to eight viewers and deployments may configure a limit from 1 through 16. Eight is an admission default, not a validated media-performance promise. A newer product target caps host media fanout at two; the current implementation does not satisfy that target above two viewers, and Proposed ADR-0004 owns the isolated experiment rather than silently changing this accepted baseline.
-- An SFU is not the default path for the normal product envelope. Severe user-observed multi-viewer degradation triggered Draft ADR-0003/PR #12 as optional infrastructure, but it is not merged, deployed, or accepted while that PR remains Draft. If configured later, it may be selected automatically only after cheaper endpoint-carried routes cannot satisfy the route contract.
+- An SFU is not the default path for the normal product envelope. Closed PR #12's explicit whole-room mode is superseded by merged PR #17's default-off automatic fallback. When a deployment configures SFU capacity, ADR-0005 permits selecting it only after cheaper endpoint-carried routes fail; that experiment is not deployed or accepted for production.
## Consequences
diff --git a/docs/adr/0004-peer-assisted-media-experiment.md b/docs/adr/0004-peer-assisted-media-experiment.md
index cdee8dc..935e998 100644
--- a/docs/adr/0004-peer-assisted-media-experiment.md
+++ b/docs/adr/0004-peer-assisted-media-experiment.md
@@ -11,9 +11,10 @@ custom packet-forwarding protocol. Real multi-viewer use later degraded severely
The product now also has a hard target that a broadcaster must never carry more
than two outgoing media edges.
-Draft PR #12 proposes an independently reversible deployment-level SFU under
-ADR-0003, but it is not merged or accepted. Making an SFU the normal path would
-move every viewer's egress cost to the server. TeamSpeak-style shared encoding
+Closed PR #12's explicit whole-room SFU proposal under ADR-0003 is historical
+and superseded. Merged PR #17 instead supplies ADR-0005's default-off automatic
+optional-SFU fallback. Making that fallback the normal path would still move
+every viewer's egress cost to the server. TeamSpeak-style shared encoding
reduces host encode work but still sends one network copy to every viewer, so it
cannot meet the new fanout target alone.
@@ -67,17 +68,23 @@ downstream `RTCPeerConnection`. The browser therefore decodes and re-encodes at
every relay hop. Existing audio follows the same stream when capture provides
it; the experiment does not invent a separate audio protocol.
-The peer-assisted wire accepts exactly `1080p60`, `1080p30`, and `720p30` as
-room quality profile IDs. The signaling server holds the current ID in bounded,
-per-room memory only; it is not persisted. An authenticated peer-assisted
-snapshot includes the current value, the host reasserts its local choice before
-reconciling assigned children, and live changes are sent to all online viewers.
-The viewer relay keeps one desired profile for its current and future child.
-Sender setup, stream replacement, and profile changes are serialized inside
-each `HostPeer`, with the latest desired value winning. The ordinary P2P
-authenticated wire remains unchanged and profile-control messages are forbidden
-in that mode. This coordinates sender targets; it does not prove achieved
-resolution, bitrate, frame rate, or resource cost.
+The authoritative peer-assisted wire value is one exact `QualitySettings`
+object, not a profile ID. It accepts only 720p/1080p/1440p, integer 15-60 fps,
+integer 2-12 Mbps, and clarity/balanced/fluid preference, with missing, extra,
+or out-of-range fields rejected. The three presets remain UI recommendations,
+not protocol states. The server keeps the latest object in bounded per-room
+memory only; it is not persisted. Authentication carries it, the host reasserts
+its local value before reconciling children, and live changes reach online
+viewers without an acknowledgement protocol.
+
+`HostPeer`, the current and future viewer relay sender, and an enabled SFU
+publisher consume that same last-wins setting. Initial sender setup, successful
+track replacement, and live changes use the same serialized mutation path and
+read back requested/applied bitrate, frame rate, scale, and preference. A
+rejected mutation rolls back or fails closed through the existing route
+controller. The ordinary P2P authenticated wire remains unchanged and rejects
+room-setting messages. These are sender ceilings and preferences, not proof of
+achieved resolution, bitrate, frame rate, or resource cost.
Encoded Transform, DataChannel media, WebCodecs rendering, dummy-sender byte
replacement, custom congestion control, codec ladders, multiple trees, mobile
@@ -102,9 +109,10 @@ viewing remains required.
## Acceptance Gate
-Test 1, 3, 5, and 8 viewers for 30 minutes across the 1080p60, 1080p30, and
-720p30 profiles with controlled per-edge RTT at or below 40 ms and loss at or
-below 1%. Current desktop Chrome and Edge form the relay cohort; current Android
+Test 1, 3, 5, and 8 viewers for 30 minutes across the three recommended ceiling
+combinations, plus any advanced combination proposed for production, with
+controlled per-edge RTT at or below 40 ms and loss at or below 1%. Current
+desktop Chrome and Edge form the relay cohort; current Android
Chrome and iOS Safari join last as leaf checks. The protocol has no
relay-capability bit, so controlled join order is the only enforcement and this
spike is not safe for arbitrary-user deployment.
@@ -120,8 +128,8 @@ The proposal advances only if every condition holds:
zero-copy forwarding;
- excluding explicit TURN paths, the application server carries no media;
- host and relay upload remain within 20% of `childCount * observedBitrate`;
-- authentication and live profile changes leave every current and future child
- sender targeting the same latest room profile without altering the P2P wire;
+- authentication and live setting changes leave every current and future child
+ sender targeting the same latest room setting without altering the P2P wire;
- first picture is at most 3 seconds, a 60 fps run does not remain below 50
decoded fps for more than 5 seconds, and depth-four p95 glass-to-glass
latency is at most 350 ms;
@@ -180,11 +188,12 @@ Negative:
- ADR-0001 remains the accepted production baseline. This proposal narrows its
peer-tree rejection only enough to run an isolated experiment.
-- Draft ADR-0003 in PR #12 remains the separate optional-SFU proposal. This
- experiment neither accepts/removes it nor implements the required automatic
- P2P/peer-assisted/SFU route controller.
+- ADR-0003/PR #12 is retained only as the rejected/superseded explicit
+ whole-room SFU history. ADR-0005 and merged PR #17 own the default-off
+ automatic optional-SFU fallback; this ADR does not accept that route for
+ production.
- If a later ADR accepts peer-assisted media, it must state exactly which parts
- of ADR-0001 and ADR-0003 it supersedes.
+ of ADR-0001 and ADR-0005 it supersedes.
## References
diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md
index 745256b..d6f0e83 100644
--- a/docs/adr/0005-automatic-hybrid-media-routing.md
+++ b/docs/adr/0005-automatic-hybrid-media-routing.md
@@ -1,6 +1,6 @@
# ADR-0005: Automatic Hybrid Media Routing
-- Status: Proposed - Draft Implementation, Experiment Only
+- Status: Proposed - Default-Off Implementation, Experiment Only
- Date: 2026-08-19
## Context
@@ -24,8 +24,9 @@ relay have a downstream budget of at most two active media edges. The current
browser relay remains stricter at one child.
ADR-0004 implements automatic peer assignment but not cross-mode fallback.
-Draft PR #12 implements a mutually exclusive process-wide `p2p|sfu` mode. That
-model cannot satisfy the required priority or minimize server egress.
+Closed PR #12 proposed a mutually exclusive process-wide `p2p|sfu` mode; this
+ADR and merged PR #17 supersede that model because it cannot satisfy the route
+priority or minimize server egress.
## Proposed Experiment
@@ -207,9 +208,9 @@ encrypted from the SFU operator; the deployment and UI must not claim otherwise.
## Implementation Order
-1. Port only LiveKit dependencies, token issuance, deployment templates, and
- thin publisher/subscriber classes from Draft PR #12. Do not merge its
- mutually exclusive protocol or page branches.
+1. Reuse only the LiveKit dependencies, token issuance, deployment templates,
+ and thin publisher/subscriber boundary developed in closed PR #12. Do not
+ restore its mutually exclusive protocol or page branches.
2. Split publisher lifecycle into connect, activate, deactivate, and profile
update; make subscriber connections selective and inactive until assigned.
3. Add versioned route state and pure invariant/property tests.
@@ -259,9 +260,9 @@ Negative:
- This proposal corrects the automatic-migration interpretation in ADR-0001
without changing the currently deployed MVP.
- ADR-0004 remains the bounded full-stream browser-relay experiment.
-- If accepted, this ADR supersedes Draft ADR-0003's process-wide explicit media
- mode with automatic hybrid fallback using the same optional LiveKit building
- blocks.
+- This experiment and merged PR #17 supersede ADR-0003/closed PR #12's
+ process-wide explicit media mode with default-off automatic hybrid fallback.
+ ADR-0003 remains historical rejected/superseded context.
- Native shared encoding and two-tree striped distribution remain orthogonal
data-plane experiments under separate decisions.
diff --git a/docs/project-memory.md b/docs/project-memory.md
index f4dbf66..5177f0b 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -17,12 +17,12 @@ Last updated: 2026-08-19
## Current Recommendation
- Keep the deployed Windows Chrome/Edge broadcaster and responsive Web viewer as the measurement baseline. Validate current Android Chrome and iOS Safari as leaves.
-- Use direct host P2P for one or two viewers. Draft ADR-0004 and Draft PR #13 own the default-off experiment for later viewers: two sticky deterministic chains, host capacity two, viewer capacity one, no proactive rebalance or composite score, and no more than eight viewers when `PEER_ASSISTED_MEDIA=true`.
+- Use direct host P2P for one or two viewers. `main` carries ADR-0004's default-off experiment for later viewers: two sticky deterministic chains, host capacity two, viewer capacity one, no proactive rebalance or composite score, and no more than eight viewers when `PEER_ASSISTED_MEDIA=true`.
- Standard browser relays resend remote `MediaStreamTrack` values and therefore decode and re-encode at every hop. Browser WebRTC does not guarantee one shared encoder across peer connections; measure this cost rather than hiding it.
- Keep the full-stream browser experiment bounded. If it needs a different media plane, mark that experiment failed and evaluate native RTP relay, encoded-object striping, SVC, or FEC in separate measured spikes rather than hiding the change inside ADR-0004.
- Plan a separate native-sender ADR for TeamSpeak-style shared encoding regardless of the browser relay result. One compatible encoded output may feed at most two standard WebRTC packetizers to reduce host encode work, but each edge still consumes upload bandwidth. This work is not part of the current Draft and cannot rescue another failed browser-relay gate.
-- Keep user-operated mini-SFU deployment and central single-node SFU Draft PR #12 as optional infrastructure. When configured, the route controller may select them automatically as the last fallback; their fanout egress belongs to their operator and does not justify Redis or multi-node infrastructure.
-- Keep automatic routing behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make and never exceeds two active edges, and a binary per-session relay capability keeps detected mobile/iPad clients as leaves. Corrected local recovery passes functionally but misses the sub-second gate.
+- Keep user-operated or central single-node SFU capacity optional. When configured, `main`'s #17 route controller may select it only as the final fallback; its fanout egress belongs to its operator and does not justify Redis or multi-node infrastructure. Closed PR #12's explicit whole-room SFU mode is superseded, not a current option.
+- Keep `main`'s automatic routing default-off behind ADR-0005: discrete failures drive peer recovery and optional SFU fallback, host media uses break-before-make and never exceeds two active edges, and a binary per-session relay capability keeps detected mobile/iPad clients as leaves. Corrected local recovery works but misses the sub-second gate.
- Prefer direct UDP, then TURN/UDP, with TURN/TCP as the required non-UDP fallback. Optional TURN/TLS uses TCP 5349 by default; TCP 443 needs a dedicated address or validated L4/SNI routing.
- Treat every quality setting as a ceiling. The current Draft defaults to clarity-first and adds bounded manual controls plus sender readback; production remains `balanced`, and its reported degradation is still unclassified. Run the 1/2/3-viewer plus TURN matrix before changing ceilings or adding any automatic controller.
- Do not add custom scene detection or dynamic-FPS control until WebRTC statistics and host resource measurements prove a material gap. Keep browser codec order until target hardware proves a more efficient common codec.
@@ -33,19 +33,19 @@ Last updated: 2026-08-19
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
-- Draft PR #21 stacks on #19's equal idle-stage share/join actions and default-closed technical details; advanced quality stays in the common control area and actionable warnings stay visible. It is not deployed.
+- `main` includes PR #19's equal idle-stage share/join actions and default-closed technical details. Draft PR #21 keeps advanced quality in the common control area and actionable warnings visible. Neither change is deployed.
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
-- Draft PR #13 contains deterministic server/client peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is neither merged nor deployed. Native shared encoding and custom encoded browser transport are not implemented.
-- Draft PR #17 stacks on PR #13: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back and disables SFU for that share. LiveKit is dormant when unconfigured.
+- `main` contains deterministic peer-assisted assignment and standard remote-track relay. It defaults off, is capped at eight viewers, re-encodes at every relay, and is not deployed. Native shared encoding and custom encoded browser transport are not implemented.
+- `main` also contains ADR-0005's default-off route controller: peer recovery precedes an allowlisted SFU root; revisioned prepare/commit/abort is session-bound; active SFU failure gets one token refresh, then fails back for that share. LiveKit is dormant when unconfigured.
- Corrected Chrome 151/LiveKit 1.13.5 smoke physically failed one leaf through peer recovery/reparent and two-root SFU. Frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure-to-render took 2.257 seconds. It is localhost, headless, synthetic 720p30 video only.
- Draft PR #16 (`5b09f0a`) proves one Pion RTP write fans equivalent data to two transports with independent SSRCs. It has no encoder and proves neither physical shared encode nor browser readiness.
- Chrome 151 short synthetic `1/3/5/8` runs kept host edges at two, relay edges at one, and all viewers decoding; a three-viewer relay close recovered in 5.32 seconds. This proves topology/control only, not quality, load, endurance, or silent partitions.
-- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only peer-assisted updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Sender updates are serialized and read back; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action.
-- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced-to-clarity propagation, sender preference readback, stable peer-connection fingerprints, host fanout two, relay fanout one, and continued decoding. This is control evidence, not a quality, load, public-network, or endurance claim.
+- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Initial, replaced, and updated senders share serialized configure/readback; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action.
+- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one. This is control evidence, not a quality, load, public-network, or endurance claim.
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
-- Draft PR #21 passes CI, repository hygiene, type checking, 20 Vitest files/260 tests, and both builds. Production separately passed 108 tests and deployment checks.
+- The review revision passes repository hygiene, type checking, 20 Vitest files/264 tests, both builds, and the Chrome control smoke. Production separately passed 108 tests and deployment checks.
## Provisional Quality Targets
@@ -71,5 +71,5 @@ These are measurement gates, not performance claims.
- Documentation index and current phase: `docs/README.md` and `docs/status.md`
- Requirements and design: `docs/需求理解.md` and `docs/方案设计.md`
- Media research: `docs/research/webrtc-p2p-screen-sharing.md`, `docs/research/realtime-quality-adaptation.md`, `docs/research/peer-assisted-media.md`, `docs/research/low-server-media-routes.md`, `docs/research/advanced-peer-distribution.md`, and `docs/research/native-shared-encode-sender.md`
-- Architecture: ADR-0001, ADR-0002, proposed experiments ADR-0004/ADR-0005, and Draft PR #12's unaccepted ADR-0003
+- Architecture: ADR-0001, ADR-0002, proposed experiments ADR-0004/ADR-0005, and historical rejected/superseded ADR-0003
- Deployment and maintenance: `docs/deployment.md`, `docs/maintenance.md`, `AGENTS.md`, and `.codex/`
diff --git a/docs/research/low-server-media-routes.md b/docs/research/low-server-media-routes.md
index 953f8f8..5277837 100644
--- a/docs/research/low-server-media-routes.md
+++ b/docs/research/low-server-media-routes.md
@@ -29,10 +29,10 @@ The smallest current plan is:
disappear.
Any browser-spike failure other than isolated relay re-encoding closes that
-browser-relay route. It does not cancel the separate native sender plan. Draft
-SFU PR #12 remains unmerged and undeployed. ADR-0005 now owns a separate Draft
-implementation of the automatic cross-mode controller; it is also undeployed
-and has not passed real LiveKit/browser network validation.
+browser-relay route. It does not cancel the separate native sender plan. Closed
+PR #12's explicit whole-room SFU mode is superseded. ADR-0005 and merged PR #17
+own the default-off automatic cross-mode controller; it remains undeployed and
+has not passed real LiveKit/browser network validation.
## Traffic Conservation And The Impossible Triangle
@@ -67,7 +67,7 @@ lost.
| Native shared-encode host | Host targets one encode for at most two standard WebRTC edges | libwebrtc public-API proxy risk spike, with Pion as fallback | Planned separate sender phase; still pays per-edge upload |
| Native volunteer encoded-RTP relay | Each volunteer forwards one encoded copy | Native install, RTP/RTCP forwarding, packaging, and opt-in relay policy | Conditional experiment only if relay re-encoding is the sole browser-spike failure |
| User-operated mini-SFU | User's SFU emits viewer copies | Separate deployment and its egress bill; running it on the host does not reduce that host's uplink | Optional capacity; automatic final fallback when enabled |
-| Central single-node SFU | Service SFU emits viewer copies | Lowest endpoint relay burden; service pays approximately `N * B` egress | Optional capacity; automatic final fallback when enabled; Draft PR #12 only |
+| Central single-node SFU | Service SFU emits viewer copies | Lowest endpoint relay burden; service pays approximately `N * B` egress | Optional capacity; automatic final fallback through merged PR #17 when enabled |
| SVC plus multiple trees | Peers emit striped layer copies across several trees | Layer scheduling, reassembly, redundancy, and more churn state | Separate conditional spike; target endpoint upload near `B` |
| Network coding | Peers or servers emit coded blocks | Generations, buffering, decoding, integrity, and a custom media plane | Trace/FEC spike only; optimize loss recovery, not clean bandwidth |
| MoQ | Publishers and MoQ relays emit object copies | New transport, packaging, player, relay, and auth stack | Optional central-fallback benchmark; still pays server egress |
diff --git a/docs/research/peer-assisted-media.md b/docs/research/peer-assisted-media.md
index 7c0dd70..3e61fad 100644
--- a/docs/research/peer-assisted-media.md
+++ b/docs/research/peer-assisted-media.md
@@ -183,41 +183,56 @@ Per-edge ICE remains independent. An edge may be direct or may use authenticated
TURN, so peer assistance reduces normal server media traffic but cannot promise
zero server traffic in restrictive networks.
-## Implemented Quality Profile Coordination
+## Implemented Bounded Quality Coordination
-The current Draft implementation coordinates one room profile across the
-peer-assisted tree without adding adaptation logic. Its strict ID set is
-`1080p60`, `1080p30`, and `720p30`, matching the existing capture and sender
-profiles. The signaling server stores the current ID in a room-count-bounded
-in-memory map, defaults to `1080p60`, includes it in peer-assisted authenticated
-snapshots, and broadcasts host changes to online viewers. The value is retained
-when sharing stops, removed when the room is abandoned or expires, and is not
-written to SQLite. The standard P2P authenticated variant is unchanged and
-profile-control messages are forbidden in that mode.
+The current Draft coordinates one strict `QualitySettings` object across the
+peer-assisted tree without adaptation logic. It accepts only 720p/1080p/1440p,
+integer 15-60 fps, integer 2-12 Mbps, and the three standard degradation
+preferences; missing, extra, or out-of-range fields fail schema validation.
+The three visible presets are recommendations rather than wire IDs. The server
+stores the latest complete object in a room-count-bounded in-memory map,
+defaults to 1080p60 at 8 Mbps with clarity priority, includes it in
+peer-assisted authenticated snapshots, and broadcasts host changes to online
+viewers. The value survives a stopped share, is removed with the room, and is
+not written to SQLite. Ordinary P2P authentication remains unchanged and
+setting-control messages are forbidden in that mode.
After peer-assisted authentication, the host reasserts its local selection
before reconciling assigned children. A viewer records the snapshot or update
before applying its assignment. `ViewerRelay` keeps a synchronously updated
-desired profile and serializes profile changes with stream replacement, so both
+desired setting and serializes setting changes with stream replacement, so both
an existing child and a later replacement start from the latest target. Inside
-`HostPeer`, initial sender configuration, stream replacement, and profile
-updates share one mutation queue; operations read the latest desired profile at
+`HostPeer`, initial sender configuration, stream replacement, and setting
+updates share one mutation queue; operations read the latest desired setting at
execution time, giving rapid changes last-wins behavior without versions or
acknowledgements.
+If the host changes quality while signaling cannot accept the room update, its
+current local senders still apply the setting but the UI reports that room sync
+is waiting for reconnect. The next successful host authentication reasserts the
+latest object; no acknowledgement state machine is added.
+
+The optional SFU publisher follows the same boundary: first publication,
+successful track replacement, and live setting changes all configure the real
+`RTCRtpSender`, then retain requested/applied bitrate, frame rate, scale, and
+preference. Configuration rejection uses the existing rollback/fail-closed
+path rather than silently leaving the publication on unverified parameters.
+
This only removes the previous fixed-1080p60 relay envelope. Browser constraints
and RTP sender parameters remain targets, so achieved bitrate, frame rate,
resolution, encode work, and cross-hop quality still require the measurement
matrix below. It does not add or imply shared encoding.
-A controlled Chromium 151 loopback smoke used one host, three viewers, and a
-synthetic 640x360/30 source. The downstream relay kept the same peer, sender,
-and signaling generations while its configured ceilings changed from
-8 Mbps/60 fps to 5 Mbps/30 fps and then 3 Mbps/30 fps. Each real
-`setParameters()` call and both host `applyConstraints()` calls succeeded, and
-the leaf kept decoding. This verifies wire ordering, relay propagation, and
-connection preservation only. It does not verify 1080p output, visual quality,
-CPU/GPU cost, TURN, public networks, or endurance.
+A controlled Chrome 151 loopback smoke used one host, three viewers, and a
+synthetic 720p30 source. Balanced and clarity settings reached every
+participant; every baseline active outbound video sender displayed the matching
+requested/applied preference; peer-connection fingerprints stayed unchanged;
+and every viewer's decoded-frame and `requestVideoFrameCallback` counters grew
+after each change. The harness brought each viewer page to the foreground for
+its render check because Chrome throttles background frame callbacks. Host
+fanout stayed at two and relay fanout at one. This is control and continuity
+evidence only, not visual-quality, full-resolution, load, TURN, public-network,
+or endurance evidence.
Peer multicast research such as SplitStream demonstrates why load-balanced,
failure-tolerant overlays normally introduce multiple trees and content
@@ -280,9 +295,10 @@ existing screen-audio track when the browser provides one. SVC/simulcast,
custom encoded transport, FEC changes, multi-tree striping, transcoding,
background mobile relay, and automatic SFU migration are excluded.
-Run 1, 3, 5, and 8 viewers for 30 minutes across the 1080p60, 1080p30, and
-720p30 profiles under controlled per-edge RTT at or below 40 ms and loss at or
-below 1%. Record topology generation and depth, selected candidate type, host
+Run 1, 3, 5, and 8 viewers for 30 minutes across the three recommended ceiling
+combinations, plus any advanced combination proposed for production, under
+controlled per-edge RTT at or below 40 ms and loss at or below 1%. Record
+topology generation and depth, selected candidate type, host
and relay upload, packets lost, jitter, frames encoded/decoded/dropped, total
encode/decode time, decoded FPS, `qualityLimitationReason`, first picture,
reparent time, CPU, GPU, and glass-to-glass latency.
diff --git a/docs/research/realtime-quality-adaptation.md b/docs/research/realtime-quality-adaptation.md
index 76349cf..b28b58e 100644
--- a/docs/research/realtime-quality-adaptation.md
+++ b/docs/research/realtime-quality-adaptation.md
@@ -125,13 +125,14 @@ or scene detector. Three consecutive samples of one non-`none` native
`qualityLimitationReason` produce one explanatory warning; a reason change or
recovery resets it and never triggers a media action.
-A Chromium 151 loopback smoke with one host, three viewers, and synthetic
-720p30 video propagated balanced then clarity settings to every participant,
-displayed both sender preference readbacks, and preserved all peer-connection
-fingerprints. Host media edges peaked at two, relay edges at one, and all three
-viewers kept decoding. This verifies controls and connection preservation, not
-visual quality, full-resolution performance, CPU/GPU cost, public networks, or
-sustained behavior.
+A Chrome 151 loopback smoke with one host, three viewers, and synthetic 720p30
+video propagated balanced then clarity settings to every participant. Every
+baseline active outbound video sender displayed the matching requested/applied
+preference, peer-connection fingerprints stayed unchanged, and every viewer's
+decoded-frame and foreground `requestVideoFrameCallback` counters grew after
+each change. Host media edges peaked at two and relay edges at one. This verifies
+controls and continuity, not visual quality, full-resolution performance,
+CPU/GPU cost, public networks, or sustained behavior.
## Deliberate Non-Goals
diff --git a/docs/research/webrtc-p2p-screen-sharing.md b/docs/research/webrtc-p2p-screen-sharing.md
index 3a418b2..2f4da60 100644
--- a/docs/research/webrtc-p2p-screen-sharing.md
+++ b/docs/research/webrtc-p2p-screen-sharing.md
@@ -120,7 +120,7 @@ IETF 对 mesh/SFU 的拓扑说明见 [RFC 7667](https://www.rfc-editor.org/rfc/r
- 若一开始就有多条 `relay`,应提示服务器带宽正在增加。
- 桌面和手机观看者使用同一个 Web 播放端;分享者不要求朋友安装完整客户端。
-新的产品目标要求分享端 fanout 永远不超过二。Proposed ADR-0004 用一个可整体删除的实验验证第三名及后续 viewer 能否由客户端转发;Draft PR #12 则保留可选单节点 SFU 作为独立对照。自动跨模式兜底是产品要求,但当前两个 Draft 都未实现它;超过小房间上限时仍建议使用外部直播服务。观察项包括:
+新的产品目标要求分享端 fanout 永远不超过二。Proposed ADR-0004 用一个可整体删除的实验验证第三名及后续 viewer 能否由客户端转发;`main` 中已合并 PR #17 按 ADR-0005 提供默认关闭的自动可选 SFU 兜底。已关闭 PR #12 的显式整房 SFU 模式已被取代,不再作为当前方案;自动路由通过真实门槛前仍不可部署,超过小房间上限时仍建议使用外部直播服务。观察项包括:
- 正常工作负载持续超过实测可承载的 P2P 人数。
- 第一名或多名观看者已使用 TURN,继续 P2P 会重复占用服务器上行。
diff --git a/docs/status.md b/docs/status.md
index 837c3e8..f38edfb 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -8,10 +8,10 @@ The WebRTC proof of concept at `https://share.bonfire.icu` runs commit
`5b2fb005f6f7` with live quality/source changes, video pause, protected SQLite
rooms, sequential IDs, and reusable links.
-Production still creates one host peer connection per viewer. Draft PR #13 adds
-default-off two-chain peer assistance with per-hop re-encoding; ADR-0005 adds
-optional automatic SFU fallback in Draft PR #17. Corrected recovery passes
-functionally but misses its sub-second gate; neither Draft is merged or deployed.
+Production still creates one host peer connection per viewer. `main` now
+contains the default-off two-chain peer assistance, optional automatic SFU
+fallback, and simplified entry/diagnostics from merged PRs #13, #17, and #19;
+none is deployed. Corrected route recovery works but misses its sub-second gate.
## Current Snapshot
@@ -21,17 +21,18 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- Direct ICE is preferred independently per media edge. Authenticated TURN/UDP and TURN/TCP are required production fallbacks; TURN/TLS is optional.
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
-- Draft PR #21 replaces the three-ID relay state with one strict, memory-only setting shared by current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings.
-- The same stack includes Draft PR #19's equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed.
+- Draft PR #21 uses one strict, memory-only quality setting for current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings.
+- It builds on `main`'s equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed.
## Verified Evidence
- Production passed 108 tests, both builds, public HTTPS/access/TURN checks, clean activation, and a database restart retaining room `1`.
-- Draft PR #13 now provides `npm run benchmark:peer-assisted`; the full check passes type checking, 13 Vitest files with 152 tests, and both production builds.
+- PR #13 introduced `npm run benchmark:peer-assisted`; its 152-test baseline and both builds passed.
- A short Chrome 151 synthetic `1/3/5/8` benchmark passed every topology check: host active edges peaked at two, relay edges at one, every viewer kept increasing decoded frames through the measurement window, and the slowest first decoded frame was about 1.05 seconds. Closing a first-level relay in the three-viewer run recovered in about 5.32 seconds without exceeding host fanout two.
-- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced-to-clarity propagation, visible sender preference readback, unchanged peer-connection fingerprints, host fanout two, relay fanout one, and continued decoding.
+- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer-connection fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one.
+- That quality smoke was localhost, headless, and video-only. It proves control propagation and continuity, not visual quality, full-resolution performance, CPU/GPU load, TURN, public networks, or endurance.
- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds.
-- Draft PR #17 passed its 253-test baseline; Draft PR #21 passes CI, repository hygiene, type checking, 20 Vitest files/260 tests, and both production builds.
+- The review revision passes repository hygiene, type checking, 20 Vitest files/264 tests, both production builds, and the Chrome control smoke above.
- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
@@ -46,13 +47,12 @@ functionally but misses its sub-second gate; neither Draft is merged or deployed
- Production users report severe resolution/bitrate/FPS degradation across all profiles. The Draft now permits bounded clarity/balanced comparison, but a controlled 1/2/3-viewer and TURN sample must still distinguish capture, per-edge CPU, path, and receiver limits before any quality claim or automatic controller.
- Browser relays do not share encoding. Encoded objects, custom congestion control, multiple trees, and network coding remain separate measured candidates.
- The long-term endpoint budget is at most two downstream edges for both the host and relay-capable viewers, with one compatible encoded output reused across both edges where a native media engine can prove it. The current browser spike remains host capacity two/viewer capacity one and performs a new encode at each relay. Packet/layer striping and multi-parent assembly are recorded, not implemented.
-- The corrected smoke is localhost/headless/video-only; public transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
## Next Milestone
-Finish ADR-0005: address the 2.257-second fallback, then validate public
-transports, rollback, reconnect, controls, edge counts, egress, and load. The
-quality/UI stack remains undeployed until its parent gates pass.
+Before enabling `main`'s experimental routes, address the 2.257-second fallback
+and validate public transports, rollback, reconnect, controls, edge counts,
+egress, and load. The quality stack remains undeployed until those gates pass.
ADR-0004 still requires a full-resolution 30-minute `1/3/5/8` network,
resource, quality, latency, recovery, and browser/mobile-leaf matrix.
@@ -66,7 +66,8 @@ separate experiments, not ways to relabel a failed browser route.
## Blockers And Decisions
-Peer assistance and automatic routing remain blocked on ADR-0004/0005 gates.
+Production enablement of peer assistance and automatic routing remains blocked
+on ADR-0004/0005 gates.
Native shared encode and multi-tree striping remain separate experiments.
- Whole-system versus selected-game audio for the first release.
diff --git a/docs/需求理解.md b/docs/需求理解.md
index d3e7e5c..0b1ed13 100644
--- a/docs/需求理解.md
+++ b/docs/需求理解.md
@@ -35,7 +35,7 @@
- [ ] 原生 shared encode 分享端:后续独立的 Electron/原生分享端阶段必须保证一份兼容编码输出扇出到最多两套独立 WebRTC packetizer,并由单独 ADR 和测量约束。它降低分享端重复编码负载,但不消除每条出边各自的上传带宽。浏览器不保证跨 `RTCPeerConnection` 复用编码器,因此当前 Web spike 不实现、也不能宣称满足 shared encode 保证。
- [ ] 原生 shared encode relay:具备原生 relay 能力的 audience 同样最多维护两条下游媒体边,并应让一份兼容编码输出供两条边复用。普通浏览器 audience 继续作为 leaf 或使用当前会逐跳重编码的实验路径,不伪称 shared encode。
- [ ] 分包/分层多树实验:把编码包、对象或可独立解码层拆到多个 peer 路径以分散上传,只能在独立 spike 中验证。必须保持亚秒级,处理多父重组、排序、丢包恢复、关键帧、同步和节点退出;未通过门槛前不进入产品路由控制器。
-- [ ] 可选 SFU:部署者可选择是否提供单节点 SFU 资源;配置后由路由控制器把它作为 P2P 与 peer-assisted 之后的自动兜底,而不要求用户选模式。它保持非默认、可逆,并承担每名观看者的服务器出口成本。Draft PR #12 仍在评审,不能视为已合并、已部署或推荐路径。
+- [ ] 可选 SFU:部署者可选择是否提供单节点 SFU 资源;配置后由 `main` 中 PR #17 的路由控制器把它作为 P2P 与 peer-assisted 之后的自动兜底,而不要求用户选模式。它保持默认关闭、可逆,并承担每名观看者的服务器出口成本。已关闭 PR #12 的显式整房 SFU 模式已被取代,不是当前选项;自动 fallback 仍须通过 ADR-0005 门槛才可部署。
- [ ] 分享端桌面封装:用 Electron 固定 Chromium 版本、改善选源/热键/系统音频体验,同时保留 Web 观看端。
- [ ] Windows 应用音频:仅捕获所选游戏及其子进程的音频,避免把语音聊天或通知声一并分享。
- [ ] 连接自检:在开始分享前验证信令、STUN、TURN/UDP、TURN/TCP 及部署实际配置的可选 TURN/TLS 可达性,并给出不暴露凭据的诊断结果。
From edf740e45ddb7e63618c57f8a25794b7b4057792 Mon Sep 17 00:00:00 2001
From: AAA <35992542+TNTcraftHIM@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:56:03 +0800
Subject: [PATCH 9/9] fix(media): surface SFU sender warnings
---
docs/project-memory.md | 6 ++--
docs/research/peer-assisted-media.md | 3 ++
docs/status.md | 4 +--
src/client/media/host-sfu-route.ts | 10 ++++++
src/client/pages/HostPage.tsx | 47 ++++++++++++++++++++++-----
src/client/pages/host-page-notices.ts | 28 ++++++++++++++++
src/client/sfu/publisher.ts | 7 +++-
tests/host-page-notice.test.ts | 35 ++++++++++++++++++++
tests/route-transition.test.ts | 26 +++++++++++++++
tests/sfu-client.test.ts | 3 ++
10 files changed, 154 insertions(+), 15 deletions(-)
create mode 100644 src/client/pages/host-page-notices.ts
create mode 100644 tests/host-page-notice.test.ts
diff --git a/docs/project-memory.md b/docs/project-memory.md
index 5177f0b..c563066 100644
--- a/docs/project-memory.md
+++ b/docs/project-memory.md
@@ -33,7 +33,7 @@ Last updated: 2026-08-19
- The repository is one npm package using Node.js 24, React, TypeScript, Vite, native WebRTC, `ws`, Zod, Vitest, and separate coturn. LAN/container listening defaults to all interfaces; bare-metal reverse-proxy deployment binds loopback and exposes `/healthz`.
- Production commit `5b2fb005f6f7` is deployed at `https://share.bonfire.icu`. It provides live source and quality changes without rebuilding healthy peers, video-only pause, clearer connection states, and the standard one-host-`RTCPeerConnection`-per-viewer path. Production therefore still exceeds the two-edge host target above two viewers.
- Production supports the three profiles with `contentHint = "motion"` and `degradationPreference = "balanced"`; its real live quality/pause cycle remains unverified.
-- `main` includes PR #19's equal idle-stage share/join actions and default-closed technical details. Draft PR #21 keeps advanced quality in the common control area and actionable warnings visible. Neither change is deployed.
+- `main` includes PR #19's equal idle-stage share/join actions and default-closed technical details. Draft PR #21 keeps advanced quality in the common control area and SFU initial/update/replacement warnings visible. Neither change is deployed.
- Access stays small: optional `ACCESS_PASSWORD`, a 12-hour stateless HMAC HttpOnly `SameSite=Strict` cookie, internal host token, role-bound signaling, Origin/payload checks, and no accounts, JWTs, or session map.
- With `ROOM_DATABASE_PATH` and the site password, built-in SQLite stores only auto-incremented room ID and host-token digest. Links persist and stopping sharing leaves viewers waiting. Production room ID `1` survived the `5b2fb005f6f7` deployment restart. Without the path, rooms remain random and temporary; public mode cannot use sequential rooms.
- Production runs behind nginx with Node.js 24.19.0. Authenticated coturn 4.17.2 at `turn.bonfire.icu:3478` has verified public STUN, TURN/UDP, TURN/TCP, and relay-only traffic. TURN/TLS is intentionally disabled.
@@ -42,10 +42,10 @@ Last updated: 2026-08-19
- Corrected Chrome 151/LiveKit 1.13.5 smoke physically failed one leaf through peer recovery/reparent and two-root SFU. Frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure-to-render took 2.257 seconds. It is localhost, headless, synthetic 720p30 video only.
- Draft PR #16 (`5b09f0a`) proves one Pion RTP write fans equivalent data to two transports with independent SSRCs. It has no encoder and proves neither physical shared encode nor browser readiness.
- Chrome 151 short synthetic `1/3/5/8` runs kept host edges at two, relay edges at one, and all viewers decoding; a three-viewer relay close recovered in 5.32 seconds. This proves topology/control only, not quality, load, endurance, or silent partitions.
-- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Initial, replaced, and updated senders share serialized configure/readback; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action.
+- The current quality stack uses one strict, non-persistent `QualitySettings` value: 720p/1080p/1440p, integer 15-60 fps, 2-12 Mbps, and clarity/balanced/fluid preference. Host-only updates are last-wins for current/future relays and optional SFU; ordinary P2P wire stays unchanged. Initial, replaced, and updated senders share serialized configure/readback; rejection, rewriting, or three identical native limitation samples produce one visible warning without automatic action. Successful replacement rollback retains the original failure warning.
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one. This is control evidence, not a quality, load, public-network, or endurance claim.
- Per-frame encode/decode diagnostics use adjacent non-overlapping `getStats()` deltas rather than connection-lifetime averages; first, empty, changed-stream, and reset intervals remain unknown and rebase.
-- The review revision passes repository hygiene, type checking, 20 Vitest files/264 tests, both builds, and the Chrome control smoke. Production separately passed 108 tests and deployment checks.
+- The review revision passes repository hygiene, type checking, 21 Vitest files/268 tests, both builds, and the Chrome control smoke. Production separately passed 108 tests and deployment checks.
## Provisional Quality Targets
diff --git a/docs/research/peer-assisted-media.md b/docs/research/peer-assisted-media.md
index 3e61fad..1696296 100644
--- a/docs/research/peer-assisted-media.md
+++ b/docs/research/peer-assisted-media.md
@@ -217,6 +217,9 @@ successful track replacement, and live setting changes all configure the real
`RTCRtpSender`, then retain requested/applied bitrate, frame rate, scale, and
preference. Configuration rejection uses the existing rollback/fail-closed
path rather than silently leaving the publication on unverified parameters.
+After initial activation or source replacement settles, the host reads and
+shows the route warning; a successful replacement rollback retains the original
+failure instead of clearing it with the restored sender readback.
This only removes the previous fixed-1080p60 relay envelope. Browser constraints
and RTP sender parameters remain targets, so achieved bitrate, frame rate,
diff --git a/docs/status.md b/docs/status.md
index f38edfb..1f5d07b 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -22,7 +22,7 @@ none is deployed. Corrected route recovery works but misses its sub-second gate.
- Hidden routing is `direct P2P -> peer-assisted -> optional SFU`. After bounded ICE recovery it tries peer reparenting before an allowlisted SFU root. Session-bound revisions prepare, commit break-before-make under two host edges, or abort. Active SFU gets one token refresh, then fails back for that share.
- A viewer starts as a leaf each session and explicitly advertises relay capacity zero or one; the Web client reports detected mobile/iPad clients as zero and desktop-class browsers as one. Withdrawal stops future assignment without moving a healthy edge. Browser relays remain one-child; the host remains two-child.
- Draft PR #21 uses one strict, memory-only quality setting for current/future relays and optional SFU; ordinary P2P wire stays unchanged. It defaults clarity-first and exposes bounded manual ceilings.
-- It builds on `main`'s equal idle-stage share/join actions and default-closed technical details; actionable warnings remain visible. It is not deployed.
+- It builds on `main`'s equal idle-stage share/join actions and default-closed technical details; SFU initial/update/replacement warnings remain visible. It is not deployed.
## Verified Evidence
@@ -32,7 +32,7 @@ none is deployed. Corrected route recovery works but misses its sub-second gate.
- Chrome 151 with one host, three viewers, and synthetic 720p30 passed balanced and clarity propagation to every participant. Every baseline active video sender showed matching preference readback, peer-connection fingerprints stayed stable, and every viewer decoded and rendered new frames after each change; host fanout was two and relay fanout one.
- That quality smoke was localhost, headless, and video-only. It proves control propagation and continuity, not visual quality, full-resolution performance, CPU/GPU load, TURN, public networks, or endurance.
- Corrected Chrome 151/LiveKit 1.13.5 synthetic 720p30 smoke physically failed the same leaf through peer recovery/reparent and a two-root SFU route. Its frames resumed; 472 hook-assisted 25 ms samples saw host edge peak two. Failure report to active took 1.481 seconds and to new render 2.257 seconds.
-- The review revision passes repository hygiene, type checking, 20 Vitest files/264 tests, both production builds, and the Chrome control smoke above.
+- The review revision passes repository hygiene, type checking, 21 Vitest files/268 tests, both production builds, and the Chrome control smoke above.
- Chrome 151 CDP checks at 320/375/390 CSS px keep the two idle-stage actions equal, on one row, 44 px high, and free of horizontal overflow. The Viewer waiting page also has no overflow; its details checkbox starts false, changes locally, and resets after navigation. These checks cover idle/waiting states, not live media.
- Draft PR [#16](https://github.com/TNTcraftHIM/Screener/pull/16) passes CI for one Pion RTP write fanned to two transports. It has no encoder and proves neither physical encode nor browser E2E.
- Local diagnostics use adjacent non-overlapping `getStats()` deltas; empty, changed-stream, and reset intervals rebase instead of publishing lifetime averages.
diff --git a/src/client/media/host-sfu-route.ts b/src/client/media/host-sfu-route.ts
index c76a154..04f917b 100644
--- a/src/client/media/host-sfu-route.ts
+++ b/src/client/media/host-sfu-route.ts
@@ -91,11 +91,21 @@ export class HostSfuRoute {
return result;
}
+ async acceptAndWait(
+ update: RouteUpdateInput,
+ acknowledge = true,
+ ): Promise {
+ const result = this.accept(update, acknowledge);
+ await this.transitionTail;
+ return result;
+ }
+
async resyncAuthoritative(
update: RouteUpdateInput,
): Promise {
const result = this.accept(update, false);
if (result !== "stale" || this.closed) {
+ await this.transitionTail;
return result;
}
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index 6b9b470..0574cb4 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -60,6 +60,7 @@ import {
limitMediaAssignment,
MAX_HOST_MEDIA_CHILDREN,
} from "../webrtc/media-assignment";
+import { sourceSwitchNotice } from "./host-page-notices";
type HostPhase = "idle" | "starting" | "live" | "ended" | "error";
@@ -239,6 +240,21 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
void route?.disconnect();
}
+ function showHostSfuQualityWarning(
+ route: HostSfuRoute,
+ generation: number,
+ ): void {
+ if (
+ isCurrentGeneration(generation) &&
+ hostSfuRouteRef.current === route
+ ) {
+ const warning = route.getQualityWarning();
+ if (warning) {
+ setNotice(warning);
+ }
+ }
+ }
+
function disposeResources(notifyServer: boolean): void {
sourceSwitchRef.current = null;
qualityChangeRef.current = null;
@@ -605,13 +621,14 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
type: "set-quality-settings",
qualitySettings: qualitySettingsRef.current,
});
- void ensureHostSfuRoute(generation).resyncAuthoritative(
- {
+ const route = ensureHostSfuRoute(generation);
+ void route
+ .resyncAuthoritative({
revision: message.routeRevision,
phase: "active",
assignment: message.routeAssignment,
- },
- );
+ })
+ .then(() => showHostSfuQualityWarning(route, generation));
return;
}
peerAssistedRef.current = false;
@@ -626,13 +643,19 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
}
if (message.type === "route-update") {
if (peerAssistedRef.current) {
- ensureHostSfuRoute(generation).accept(message);
+ const route = ensureHostSfuRoute(generation);
+ void route
+ .acceptAndWait(message)
+ .then(() => showHostSfuQualityWarning(route, generation));
}
return;
}
if (message.type === "sfu-config") {
if (peerAssistedRef.current) {
- void ensureHostSfuRoute(generation).acceptConfig(message);
+ const route = ensureHostSfuRoute(generation);
+ void route
+ .acceptConfig(message)
+ .then(() => showHostSfuQualityWarning(route, generation));
}
return;
}
@@ -957,6 +980,10 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
failedPeerIds.push(peerId);
}
}
+ const sfuWarning =
+ activeSfuRoute && hostSfuRouteRef.current === activeSfuRoute
+ ? activeSfuRoute.getQualityWarning()
+ : null;
if (
!sfuReplaced &&
activeSfuRoute &&
@@ -997,9 +1024,11 @@ export function HostPage({ onAuthorizationRequired }: HostPageProps = {}) {
sourceSwitchRef.current === token
) {
setNotice(
- failedPeerIds.length > 0
- ? "分享来源已切换,部分观看者正在重新连接"
- : "分享来源已切换",
+ sourceSwitchNotice({
+ failedPeerCount: failedPeerIds.length,
+ sfuReplaced,
+ sfuWarning,
+ }),
);
}
} finally {
diff --git a/src/client/pages/host-page-notices.ts b/src/client/pages/host-page-notices.ts
new file mode 100644
index 0000000..7cbf5df
--- /dev/null
+++ b/src/client/pages/host-page-notices.ts
@@ -0,0 +1,28 @@
+export function sourceSwitchNotice({
+ failedPeerCount,
+ sfuReplaced,
+ sfuWarning,
+}: {
+ failedPeerCount: number;
+ sfuReplaced: boolean;
+ sfuWarning: string | null;
+}): string {
+ const peerWarning =
+ failedPeerCount > 0 ? "部分观看者正在重新连接" : null;
+ if (!sfuReplaced) {
+ return [
+ sfuWarning ?? "SFU 分享来源未切换成功,正在恢复观看连接",
+ peerWarning,
+ ]
+ .filter((message): message is string => message !== null)
+ .join(";");
+ }
+ if (sfuWarning) {
+ return [sfuWarning, peerWarning]
+ .filter((message): message is string => message !== null)
+ .join(";");
+ }
+ return peerWarning
+ ? `分享来源已切换,但${peerWarning}`
+ : "分享来源已切换";
+}
diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts
index 42204db..c780033 100644
--- a/src/client/sfu/publisher.ts
+++ b/src/client/sfu/publisher.ts
@@ -268,6 +268,10 @@ export class SfuPublisher {
if (!this.owns(room, generation)) {
return false;
}
+ const failureWarning =
+ error instanceof Error && error.message
+ ? `切换 SFU 分享来源失败:${error.message}`
+ : "切换 SFU 分享来源失败";
// A rejected publish/unpublish may have changed server state without
// returning enough ownership information to undo it safely.
@@ -298,7 +302,8 @@ export class SfuPublisher {
if (!this.owns(room, generation)) {
return false;
}
- this.retainSenderParameters(senderParameters);
+ this.senderParameters = senderParameters;
+ this.qualityWarning = failureWarning;
}
return false;
} catch (rollbackError) {
diff --git a/tests/host-page-notice.test.ts b/tests/host-page-notice.test.ts
new file mode 100644
index 0000000..a5538f2
--- /dev/null
+++ b/tests/host-page-notice.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+
+import { sourceSwitchNotice } from "../src/client/pages/host-page-notices.ts";
+
+describe("sourceSwitchNotice", () => {
+ it("does not report success when the SFU source replacement failed", () => {
+ expect(
+ sourceSwitchNotice({
+ failedPeerCount: 0,
+ sfuReplaced: false,
+ sfuWarning: null,
+ }),
+ ).toBe("SFU 分享来源未切换成功,正在恢复观看连接");
+ });
+
+ it("surfaces an SFU sender warning after a successful replacement", () => {
+ expect(
+ sourceSwitchNotice({
+ failedPeerCount: 1,
+ sfuReplaced: true,
+ sfuWarning: "浏览器改写了 SFU 发送参数",
+ }),
+ ).toBe("浏览器改写了 SFU 发送参数;部分观看者正在重新连接");
+ });
+
+ it("reports success only when every active route replacement succeeded", () => {
+ expect(
+ sourceSwitchNotice({
+ failedPeerCount: 0,
+ sfuReplaced: true,
+ sfuWarning: null,
+ }),
+ ).toBe("分享来源已切换");
+ });
+});
diff --git a/tests/route-transition.test.ts b/tests/route-transition.test.ts
index c71bbde..1c56596 100644
--- a/tests/route-transition.test.ts
+++ b/tests/route-transition.test.ts
@@ -187,6 +187,32 @@ describe("MediaRouteTransition", () => {
});
describe("HostSfuRoute", () => {
+ it("settles authoritative activation before exposing its quality warning", async () => {
+ const publisher = {
+ ...createFakePublisher([], "publisher"),
+ getQualityWarning: vi.fn(() => "SFU sender parameters were rewritten"),
+ };
+ const route = new HostSfuRoute({
+ getStream: () => ({}) as MediaStream,
+ getProfile: () => QUALITY_PROFILES["720p30"],
+ reconcileChildren: () => undefined,
+ send: () => true,
+ createPublisher: () => publisher,
+ });
+ const assignment = hostAssignment("generation-a");
+
+ route.accept({ revision: 1, phase: "prepare", assignment });
+ await route.acceptConfig(sfuConfig(1));
+ await expect(
+ route.resyncAuthoritative({ revision: 1, phase: "active", assignment }),
+ ).resolves.toBe("accepted");
+
+ expect(publisher.activate).toHaveBeenCalledOnce();
+ expect(route.getQualityWarning()).toBe(
+ "SFU sender parameters were rewritten",
+ );
+ });
+
it("reports a publisher prepare failure without requesting refresh", async () => {
const messages: ClientMessage[] = [];
const publisher = createFakePublisher([], "publisher");
diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts
index d9f45b7..8550356 100644
--- a/tests/sfu-client.test.ts
+++ b/tests/sfu-client.test.ts
@@ -441,6 +441,9 @@ describe("SfuPublisher", () => {
expect(localTrack.currentTrack).toBe(previousVideo);
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(3);
expect(publisher.getSenderParameters()?.applied.maxBitrate).toBe(8_000_000);
+ expect(publisher.getQualityWarning()).toBe(
+ "切换 SFU 分享来源失败:replacement parameters rejected",
+ );
});
it("disconnects fail-closed when replacement rollback also fails", async () => {