fix(sfu): retain live quality across republish [skip ci]

Merge reviewed video quality-tier regression fix. Production deployment follows exact merged SHA.
This commit is contained in:
AAA authored and GitHub committed 2026-08-21 19:14:23 +08:00
1 parent 647070d278
commit 252b6d69c0
16 files changed
+364 -108

No files matched your search

@@ -148,13 +148,15 @@ evidence windows remain limited to topology eligibility and diagnostics:
2. **Pinned LiveKit two-layer simulcast: implemented; acceptance remains open.**
Client 2.22.0 can publish a screen-share original plus one lower simulcast
encoding. `RemoteTrackPublication.setVideoQuality(HIGH)` sets a per-subscriber
spatial-quality ceiling. Server 1.13.5 maps quality, dimensions, and FPS to
maximum spatial/temporal layers and can adapt each SFU downtrack to its own
bandwidth and recover it independently. Server Dynacast
takes the maximum requested quality and enables every quality at or below
it, so any `HIGH` root keeps `LOW` active. That cumulative behavior prevents
dynamic `LOW` stop while `HIGH` is subscribed, but dynamic stop is now an
optimization rather than a hard requirement.
spatial-quality ceiling. Server 1.13.5 derives requested spatial/temporal
maxima from quality, dimensions and FPS, but actual selection is
codec-specific: VP8 has temporal selection, while H.264/H.265 simulcast is
spatial-only. The current H.264 candidate can adapt each SFU downtrack
between `q,h` spatial representations and recover it independently. Server
Dynacast takes the maximum requested quality and enables every quality at or
below it, so any `HIGH` root keeps `LOW` active. That cumulative behavior
prevents dynamic `LOW` stop while `HIGH` is subscribed, but dynamic stop is
now an optimization rather than a hard requirement.
The candidate therefore publishes exactly `HIGH+LOW` with standard
simulcast/send encodings, leaves each LiveKit root's ceiling at `HIGH`, and
@@ -198,10 +200,12 @@ evidence windows remain limited to topology eligibility and diagnostics:
layers, so it provides no low-resolution base and exceeds this product's
two-active-layer ceiling. Its `RemoteTrackPublication.setVideoQuality()`
controls per-subscriber spatial quality, while server 1.13.5 maps
quality/dimensions/FPS to maximum spatial/temporal layers. That selection
exists only on an SFU downtrack; it does not extend to
Screener's direct/peer receivers. The current subscriber only requests the
publication, and LiveKit documents that Dynacast can pause an entire SVC
quality/dimensions/FPS to requested spatial/temporal maxima. Actual selection
remains codec-dependent: VP8 has a temporal selector, whereas H.264/H.265
simulcast is spatial-only. That selection exists only on an SFU downtrack;
it does not extend to Screener's direct/peer receivers. The current
subscriber only requests the publication, and LiveKit documents that
Dynacast can pause an entire SVC
stream but not individual SVC layers. Chrome ships the outgoing-track API;
current Firefox and WebKit WebIDL do not expose the standard
`scalabilityMode` member, and Firefox's implementation remains behind a
+8 -5
View File
@@ -41,11 +41,14 @@ target; exact `261e980c3a9ff2d1a6b54ce18cf3daedb491b777` and
This release keeps one stable SFU subscriber stream, clears unavailable SFU
video state, stays neutral until current media evidence proves P2P or SFU, and
labels TURN plus a protocol only from actual local relay stats. Routing remains
automatic across direct/peer and bounded fallback paths. P2P reapplies the
selected quality profile after each answer on the same sender; the browser still
owns degradation. P2P/browser-relay offers prefer H.264 through standard codec
ordering, and SFU publication requests H.264 with backup codec disabled; the
complete browser list retains negotiated VP8 fallback. The Host preview notice,
automatic across direct/peer and bounded fallback paths. This exact deployed
release reapplies the selected P2P quality profile after each answer; the
candidate source removes that answer-time workaround because its confounded
A/B did not establish causality. Until that candidate is deployed, the browser
still owns degradation under the exact-release behavior. P2P/browser-relay
offers prefer H.264 through standard codec ordering, and SFU publication
requests H.264 with backup codec disabled; the complete browser list retains
negotiated VP8 fallback. The Host preview notice,
shared entry/nickname UI, and two
downstream slots per Web
relay remain deployed; a third child remains rejected. Production
+1 -1
View File
@@ -51,7 +51,7 @@ Last updated: 2026-08-21
- Production requires independent `SITE_ACCESS_PASSWORD`: its stateless cookie authorizes creation/Host and code-only Viewer attempts; private grants stay direct, while public/private code entry needs site access alone/plus room password. Env/endpoint/cookie/nginx naming migrated atomically; failures remain neutral; there are no accounts/JWT/session rows. Local `gate:access-privacy` proves fragment isolation, site/grant transport-log absence, and raw room-password SQLite absence; room-password WebSocket/log ingress and live/headful evidence remain open.
- Production uses nginx, Node.js 24.19.0, coturn 4.17.2, and LiveKit 1.13.5 on UDP 7882 (TCP fallback off). Ordinary peer ICE receives no TURN; the selected-edge tuple is configured only for the current controller edge, with no credential pre-advertised to ordinary peers. Coturn retains the old authenticated-relay ports. Services are healthy with zero restarts.
- ADR-0005 rejects `PEER_ASSISTED_ROOM_IDS`; `PEER_ASSISTED_MEDIA=true` enables every room and ordinary peers stay STUN-only. Initial or active Host ingress may consume one bound relay-only grant; ready/abort clears it and failure restores peer baseline. Stale room-ID config fails startup; `screener-v2` is the only deployed wire.
- Web SFU uses LiveKit 2.22.0 `q,h`; local SFU/UDP and selected-TURN ingress work, while BWE remains open. P2P now reapplies its selected profile after each answer on the same sender. A local A/B supports the lifecycle candidate, but natural ramp, real capture, production, and SFU validation remain open.
- Defaults are clarity-first; balanced/fluid stay explicit. P2P writes before offer or explicit source/profile changes. Web SFU uses LiveKit 2.22.0 `q,h`; SFU/UDP and selected-TURN work; BWE is open. The lifecycle candidate retains sender/SDK/restart/republish state with rollback; Host A+B, capture and production remain open.
- Chrome 151 five-Viewer 720p30 cap2 evidence: Host/relay fanout two; both relay children +80 decoded frames/~1.1 MB; every Viewer decoded; clean stop. Functional only; resource and heterogeneous-network behavior remain open.
## Provisional Quality Targets
+1 -1
View File
@@ -201,7 +201,7 @@ 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 balanced priority, includes it in
defaults to 1080p60 at 8 Mbps with clarity-first 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
+80 -30
View File
@@ -11,11 +11,14 @@ 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 current bounded implementation retains the game-oriented `motion` hint and
uses `balanced` for every recommended profile and the advanced-settings initial
value. Clarity and fluid remain explicit choices. The WebRTC API describes a
user-agent preference rather than its algorithm, so the browser controls the
actual degradation and Screener observes the result through readback and stats.
The user previously accepted `balanced` as the recommended-profile and advanced
default. Later field feedback reported default blur and apparently overcorrected
fluid behavior, so the user explicitly superseded that decision: current source
returns those defaults to clarity-first `maintain-resolution`, while `balanced`
and `maintain-framerate` remain explicit choices. Independently, the P2P
answer-time whole-profile workaround rested on confounded evidence and is
removed below. These preferences still leave actual degradation to the browser;
Screener observes readback and stats.
The inspected Chromium/libwebrtc source chain makes a screen-only shortcut
especially unsafe to assume for Screener. The JavaScript `motion` hint reaches
@@ -88,7 +91,7 @@ are ceilings rather than targets. Raising them cannot repair CPU or bandwidth
limitation and should only follow evidence that the encoder is already pinned
to the ceiling while spare transport capacity remains.
## Initial Balanced Lifecycle Evidence
## Confounded Same-Setting Observation
On 2026-08-21 one bounded local Chrome 151 run used exact source `b447ab6`, a
synthetic 1920x1080 at 60 fps capture, and one direct UDP Viewer. After the
@@ -114,13 +117,12 @@ Static review does not support the simpler claim that Chromium discards every
pre-negotiation `setParameters()` call. Current libwebrtc stores sender init
parameters while no SSRC exists and transfers them into the negotiated sender.
The observed transition followed the same-setting action, but this single run
cannot attribute it to sender lifecycle or the reapply itself. The bounded
implementation change refreshes the same video-sender profile after each
accepted answer, serialized with existing sender mutations. It
changes no track, PeerConnection, ceiling, degradation preference, or room
setting and adds no periodic controller. A post-change real-capture P2P check
must still confirm that the negotiated sender reapply, without a new capture
generation, clears the reported startup condition.
cannot attribute it to sender lifecycle or the reapply itself and therefore
does not support an answer-time whole-profile workaround. Current source keeps
the pre-offer sender configuration and serialized explicit source/profile
updates, but an accepted answer itself does not rewrite the profile. A future
workaround requires a controlled, real-capture reproduction that isolates one
intervention from stock BWE ramp-up.
This evidence does not justify an SFU change. The current SFU publisher always
publishes the ordered `q,h` pair when an SFU route is active and configures its
@@ -142,6 +144,48 @@ server traffic but may still consume compositor/GPU work. Compare the exact
release and current `main` under one fixture, with preview on/off as a separate
binary intervention.
## SFU Profile Lifecycle And Publisher Evidence
A 2026-08-21 report says that selecting fluid preference on an SFU path could
retain low received FPS without reducing the visible resolution. This is not
proof that the preference was ignored: `maintain-framerate` is a degradation
tradeoff rather than an FPS target. It is also not evidence of SFU temporal
downlayering on Screener's current H.264 `q,h` publication. Pinned LiveKit
server 1.13.5 installs a temporal selector for VP8, but its H.264/H.265 path
installs only the simulcast spatial selector. A `HIGH` ceiling may therefore
let current H.264 BWE choose the lower-resolution `q` representation, not a
lower temporal layer at the same resolution. When resolution remains stable,
classify the case in A/B/C order: capture FPS, Host publisher outbound FPS and
limitation, then Viewer inbound FPS and decode.
Pinned LiveKit client 2.22.0 keeps three relevant pieces of state. Its public
`LocalVideoTrack.setDegradationPreference()` updates the saved preference used
when a sender is installed; `LocalVideoTrack.publishOptions` drives encoding
recomputation after a track restart; and `LocalTrackPublication.options` is the
input to `republishAllTracks()`. The previous Screener update path configured
the raw sender and replaced only `track.publishOptions`, so a later SDK
republish could read the initial publication preference. The bounded fix uses
the existing publisher operation queue and rollback: call the SDK preference
API to update its saved state, configure/read back the current `q,h` sender as
the final write, then assign one merged option object to both retained
locations. Failure reapplies the previous profile; generation loss cannot
retain the result as current publisher state. This changes no capture
constraint, codec, representation, subscriber layer or route policy and does
not explain an immediate same-publication report.
Host SFU publisher A+B remains a separate observability slice. The smallest
design is one two-second, publication-generation-bound local sampler owned by
`SfuPublisher`: merge its video/audio `LocalTrack` reports, reuse the existing
strict stats parser and accumulator, correlate capture settings from the owned
video track, and emit only while the same publication is active. Activation,
replacement, deactivation and disconnect reset its identity and interval
baseline. `HostSfuRoute` may expose that local snapshot to one Host-only
publisher row; it must not duplicate the shared Host-to-SFU ingress inside each
SFU Viewer card. Viewer inbound remains the per-Viewer C signal. This needs no
wire, server telemetry, global score, selector or new UI framework, but crosses
publisher sampling, route ownership and Host rendering and therefore is not
bundled into the lifecycle repair.
## Codec Preference And Evidence Boundary
RFC 7742 requires WebRTC browsers to implement both VP8 and H.264 Constrained
@@ -375,9 +419,12 @@ encoder, CPU work, or GPU allocation.
Pinned LiveKit 2.22.0 can publish screen-share original plus one lower
simulcast encoding. `RemoteTrackPublication.setVideoQuality(HIGH)` sets a
per-subscriber spatial-quality ceiling. Server 1.13.5 maps quality, dimensions,
and FPS to maximum spatial/temporal layers and can adapt each SFU downtrack to
its own bandwidth and recover it independently. Server Dynacast
per-subscriber spatial-quality ceiling. Server 1.13.5 derives requested
spatial/temporal maxima from quality, dimensions and FPS, but applies them
through codec-specific selectors: VP8 has temporal selection, while H.264/H.265
simulcast is spatial-only. The current H.264 candidate can adapt each SFU
downtrack between `q,h` spatial representations and recover it independently.
Server Dynacast
takes the maximum quality requested across subscribers and subscriber nodes,
then enables every quality at or below that maximum; a `HIGH` root therefore
keeps `LOW` active. That prevents dynamic `LOW` stop while `HIGH` is subscribed,
@@ -484,9 +531,11 @@ as boundaries, not UA quality rankings:
Pinned LiveKit can select layers per SFU subscriber: client 2.22.0 exposes
`RemoteTrackPublication.setVideoQuality()`, and server 1.13.5 maps each
subscriber's quality/dimensions/FPS to maximum spatial/temporal layers on that
subscriber's downtrack. That useful SFU contract still does not extend to
direct/peer receivers. Screener's current SFU subscriber calls only
`setSubscribed(true)`, so it does not own a layer choice today.
subscriber's downtrack. The actual selector remains codec-dependent: VP8 has a
temporal selector, whereas H.264/H.265 simulcast is spatial-only. That useful
SFU contract still does not extend to direct/peer receivers. Screener's current
SFU subscriber calls only `setSubscribed(true)`, so it does not own a layer
choice today.
For this screen-share product the pin has an additional hard mismatch. Client
2.22.0 overwrites SVC screen-share publication to `L1T3`, even when another
@@ -550,8 +599,9 @@ resolution, frame rate, or bitrate.
current sender with `RTCRtpSender.setParameters()`. It does not reopen the
source picker or renegotiate healthy peer connections.
- The video track keeps `contentHint = "motion"`; recommended profiles and the
advanced initial value use `balanced`, with explicit `maintain-resolution`
and `maintain-framerate` choices. None promises an emitted resolution or rate.
advanced initial value use clarity-first `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,
@@ -565,10 +615,9 @@ resolution, frame rate, or bitrate.
- 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.
- A P2P sender receives that update before its first offer and once more after
each accepted answer. The negotiated reapply uses the current selected
profile and existing sender; it neither rebuilds the connection nor changes
the browser-owned degradation decision.
- A P2P sender receives its profile before the first offer. Explicit source or
profile changes use the existing serialized sender-mutation path; accepting
an answer does not trigger an extra whole-profile write.
- One strict room setting is last-wins for current/future peer relays and the
configured SFU publisher. Ordinary P2P keeps that state local and does not add
it to the authenticated wire.
@@ -626,9 +675,9 @@ manifest for time series and percentiles rather than adding a server telemetry
pipeline.
Compare image readability and motion continuity instead of declaring success
from FPS alone. If reproducible evidence later shows that `balanced` still
oscillates or makes the wrong tradeoff on supported machines, revise the fixed
profiles before enabling ADR-0007. Its acceptance matrix must prove that built-in
from FPS alone. If reproducible evidence later shows a supported preference
oscillates or makes the wrong tradeoff on supported machines, revise that
explicit option before enabling ADR-0007. Its acceptance matrix must prove that built-in
LiveKit BWE independently moves one shaped SFU leaf to the shared `LOW` and back
while a healthy leaf remains `HIGH`, without an application media selector. The
application's asymmetric evidence windows must affect only confirmed topology
@@ -673,12 +722,13 @@ is a separate optimization.
- [LiveKit degradation defaults](https://github.com/livekit/client-sdk-js/blob/main/src/room/participant/publishUtils.ts)
- [LiveKit video simulcast and Dynacast](https://docs.livekit.io/transport/media/advanced/)
- [LiveKit client 2.22.0 SVC defaults](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/track/options.ts)
- [LiveKit client 2.22.0 screen-share SVC override](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/participant/LocalParticipant.ts)
- [LiveKit client 2.22.0 screen-share SVC and republish lifecycle](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/participant/LocalParticipant.ts)
- [LiveKit client 2.22.0 start-bitrate negotiation](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/PCTransport.ts)
- [LiveKit client 2.22.0 SVC encoding construction](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/participant/publishUtils.ts)
- [LiveKit client 2.22.0 subscriber quality control](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/track/RemoteTrackPublication.ts)
- [LiveKit server 1.13.5 per-subscriber layer application](https://github.com/livekit/livekit/blob/v1.13.5/pkg/rtc/subscribedtrack.go)
- [LiveKit client 2.22.0 Dynacast layer control](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/track/LocalVideoTrack.ts)
- [LiveKit server 1.13.5 codec-specific layer selectors](https://github.com/livekit/livekit/blob/v1.13.5/pkg/sfu/forwarder.go)
- [LiveKit client 2.22.0 Dynacast and saved degradation preference](https://github.com/livekit/client-sdk-js/blob/v2.22.0/src/room/track/LocalVideoTrack.ts)
- [LiveKit server 1.13.5 Dynacast quality aggregation](https://github.com/livekit/livekit/blob/v1.13.5/pkg/rtc/dynacast/dynacastqualityvideo.go)
- [LiveKit server 1.13.5 enabled-quality generation](https://github.com/livekit/livekit/blob/v1.13.5/pkg/rtc/dynacast/dynacastmanagervideo.go)
- [LiveKit server 1.13.5 release assets and checksums](https://github.com/livekit/livekit/releases/tag/v1.13.5)
+1 -1
View File
@@ -15,7 +15,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
## Current Snapshot
- Capture precedes room creation; source/quality changes and AV pause preserve peers. Recommended profiles and advanced defaults use `balanced`; clarity/fluid remain explicit. P2P reapplies the selected profile after each answer on the same sender; the browser still owns degradation and stats/readback expose it.
- Capture precedes rooms; source/quality changes and AV pause preserve peers. Defaults are clarity-first; balanced/fluid stay explicit. P2P writes before offer or explicit changes, never on answer. The SFU lifecycle candidate retains sender/SDK/restart/republish options with rollback. Browser degradation and Host SFU A+B remain open.
- Production `screener-v2` admits private fragment grants directly. Code-only Viewers need site access, then public rooms accept the code while private rooms also require their password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and its cookie are deployed atomically; raw credentials are never stored.
- Source UI uses adjacent nickname editing, distinct site/room prompts, and a shared room-code form. Status and details stay neutral before media proof; current evidence labels green P2P/yellow `SFU fallback`, while relay evidence adds `TURN` plus only a locally observed protocol.
- Native v2 is source-only (memory rooms, 300s reclaim); it follows authenticated direct-child assignments up to two and fails unsupported SFU/selected ingress boundedly.
+3 -3
View File
@@ -152,7 +152,7 @@ Zod 在服务边界拒绝未知类型、超长字符串、非法 SDP/candidate
| 1080p30 | 1920x1080、30 fps | 5 Mbps/peer |
| 720p30 | 1280x720、30 fps | 3 Mbps/peer |
浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。Current source 保留有界手动质量控制:视频 track 使用 `contentHint = "motion"`,返回的音频 track 使用 `contentHint = "music"`;二者都是内容提示而不是 codec 或质量档位。三档推荐质量与折叠高级区初始值统一采用 `degradationPreference = "balanced"`,并允许显式选择 clarity 或 fluid。高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。
浏览器可返回更低设置,界面以 `track.getSettings()` 显示实际捕获结果。Current source 保留有界手动质量控制:视频 track 使用 `contentHint = "motion"`,返回的音频 track 使用 `contentHint = "music"`;二者都是内容提示而不是 codec 或质量档位。三档推荐质量与折叠高级区初始值统一采用 `degradationPreference = "maintain-resolution"`(clarity-first),并允许显式选择 `balanced` 或 `maintain-framerate`(fluid)。高级区将设置严格限制为 720p/1080p/1440p、15 至 60 整数 FPS、2 至 12 Mbps,以及 `maintain-resolution`、`balanced`、`maintain-framerate` 三种偏好。它们只是 ceiling 和浏览器偏好,不保证实际分辨率、帧率或码率。
每次 `setParameters()` 都必须从当次 `getParameters()` 派生更新,然后读回 requested/applied `maxBitrate`、`maxFramerate`、`scaleResolutionDownBy` 和 degradation preference。浏览器负责具体降级,实际结果由 stats 观测;拒绝或浏览器改写必须进入可见状态,不能只写 `console.warn`。
@@ -194,7 +194,7 @@ Host 本地预览直接绑定现有 capture stream,不创建 viewer、`RTCPeer
Viewer 可通过已鉴权、绑定当前房间/路径代次的控制面报告需要 `LOW`,但它只是一条拓扑观测输入,不能命令内建 WebRTC/LiveKit 媒体层。报告必须限频、去重,并与 sender 的 Transport-CC/GCC、实际码率、RTT、丢包、重传及编码限制、派生协商 codec/profile/parameters(适用时含 `scalabilityMode`)和 viewer 的实际解码/丢帧/冻结表现交叉验证。UA、平台和设备型号不参与质量或 relay capacity 决策。
SVC 的标准能力门禁已经静态关闭为 `no-go-web-svc-cross-path-hardware-contract`。WebRTC-SVC 只配置单个 outgoing sender,direct/peer receiver 没有对应的按层选择 API,独立 PeerConnection 之间也没有 shared-encode 契约;requested/applied codec/`scalabilityMode`、Media Capabilities `powerEfficient` 与 RTCStats encoder 字段不能证明编码器一定是硬件。Pinned LiveKit 虽能在 SFU 内为每个 subscriber 设置最大空间/时间层,但 client 2.22.0 会把 screen-share SVC 强制成三层 temporal-only `L1T3`,既没有低分辨率 base,也超过最多两层的门槛;这种 SFU 选层也不延伸到 direct/peer descendants。当前静态结论不运行 browser harness。未来只能由能明确锁定硬编、最多两层、跨 direct/peer/SFU 复用同一输出且通过游戏性能/延迟门槛的 native sender 以新决策重开。普通非分层编码包不能只靠转发生成另一档画质,实现路径差异只能用第二表示、合格的 scalable layers 或 relay/SFU 转码。
SVC 的标准能力门禁已经静态关闭为 `no-go-web-svc-cross-path-hardware-contract`。WebRTC-SVC 只配置单个 outgoing sender,direct/peer receiver 没有对应的按层选择 API,独立 PeerConnection 之间也没有 shared-encode 契约;requested/applied codec/`scalabilityMode`、Media Capabilities `powerEfficient` 与 RTCStats encoder 字段不能证明编码器一定是硬件。Pinned LiveKit 虽能在 SFU 内为每个 subscriber 设置最大空间/时间层请求,但实际选择依 codec/selector 而异:server 1.13.5 只为 VP8 安装 temporal selector,当前 H.264 `q,h` simulcast 只有空间层选择。Client 2.22.0 还会把 screen-share SVC 强制成三层 temporal-only `L1T3`,既没有低分辨率 base,也超过最多两层的门槛;这种 SFU 选层也不延伸到 direct/peer descendants。当前静态结论不运行 browser harness。未来只能由能明确锁定硬编、最多两层、跨 direct/peer/SFU 复用同一输出且通过游戏性能/延迟门槛的 native sender 以新决策重开。普通非分层编码包不能只靠转发生成另一档画质,实现路径差异只能用第二表示、合格的 scalable layers 或 relay/SFU 转码。
质量控制不放宽媒体拓扑预算:host 仍最多两条下游媒体边;native relay 只转发选定表示的编码包,不解码重编码。后续双树或条带化可把 host 上传从约两份完整码率推向一份加必要冗余,但不阻塞路径隔离双表示的实现。
@@ -395,7 +395,7 @@ STUN-only;只有 controller-selected edge 可接收一次短期 grant。该边
固定 `2` 是当前上线档而非永久产品上限;后续统一 evidence-driven controller 可在独立验收后使用离散 `0/1/2/3`,不按 device/UA 分支,并由持续 sender bitrate、`availableOutgoingBitrate`、区间 encode time、`qualityLimitationReason`、loss/RTT 健康窗、server deployment hard ceiling 与 cooldown 共同限界。本次 slice 不实现该动态控制。H.264、hardware encode 或 shared encode 可降低编码成本或同观感码率,但每个 child 的网络副本仍独立存在。
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、`balanced`,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一完整设置;普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送同步消息都会被拒绝。启用 SFU 时 publisher 也使用这一设置,不另建一套质量协议。
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、`maintain-resolution`,不写入 SQLite;停止分享时保留,房间被放弃或过期时删除。只有 peer-assisted host 可更新,鉴权快照和在线广播携带同一完整设置;普通 P2P 的严格鉴权 wire 保持原样,host 或 viewer 在该模式发送同步消息都会被拒绝。启用 SFU 时 publisher 也使用这一设置,不另建一套质量协议。
Viewer 在处理 peer-assisted 鉴权快照时先更新 `ViewerRelay` 的 desired settings,再应用拓扑分配;同一个串行队列协调 remote stream、当前 child 更新和以后重建的 child,因此它们读取同一最新目标。每个 `HostPeer` 也把初始 sender 配置、`replaceStream()` 和 `updateProfile()` 放进同一 sender-mutation queue;desired settings 在入队前同步更新,已排队操作执行时读取最新值,使快速连续修改保持 last-wins,而不增加版本号或确认协议。这里同步的是浏览器 sender 的目标上限,不是实际分辨率、帧率、码率或 shared encoding 的性能保证。
+2 -1
View File
@@ -27,7 +27,7 @@
- [ ] 端点 fanout 硬上限:任一非服务器端点任何时刻最多维护两条 active downstream media edges,包括加入、断线、换父和恢复过程;上游接收不计入这项上传预算。普通 Web relay 统一声明最多两个 child,不按 UA、设备类型或页面可见性另建容量策略;实际 edge 失败进入同一有界换父路径。第三名及后续观看者由系统自动分配给通过验收的 peer-assisted 拓扑,不能隐藏地回退为 Host 第三条连接。没有可行 peer path 时,SFU 作为主要中央兜底 virtual parent 正常向一至两个必要 roots 送流;媒体到达 root 后其余可行观看者仍优先沿受限 peer descendants 扩散。多个无法挂到健康 root 的极端 viewer 可成为额外 server-fed 例外,但必须受显式中央 egress/admission 上限约束,不能无界整房 fanout。TURN 只作为可选的极端网络 transport 兼容层,不定义房间拓扑。
- [ ] P2P 优先:每条逻辑媒体边的 ICE 必须先尝试直接 UDP 路径,服务器不得默认中转可直连的媒体。一个或两个观看者优先直接连接分享端;更多观看者的 peer-assisted 行为须先通过 ADR-0004 的实验门槛。
- [ ] NAT 与传输兜底:STUN 必须可用,所有 ordinary peer `RTCPeerConnection` 只接收 STUN ICE servers。PC 失败后按一次 ICE restart、一次同 parent rebuild、一个 alternate peer、再 SFU/UDP virtual parent 的顺序恢复,任一步成功即停止;只有该 edge 的 SFU/UDP 也失败后,controller 才可授权一次短期 authenticated selected-edge TURN/UDP rebuild。授权必须绑定当前 room/share generation、viewer/parent session、route revision 与 old/new connection identity,成功、失败、超时或代次变化即消费;同一房间同时 pending 或 active 的 `peer-selected` attempt 总数最多为一,`host-sfu-ingress` 使用独立状态且不占该 last-mile 名额。不得按 participant、房间或普通 peer PC 下发,Native v2 保持 STUN-only。凭据不进入 URL、日志、浏览器持久化或 SQLite。Coturn 只验证 HMAC 与 expiry,应用必须重验 edge 代次并用短 TTL、one-use state 与 quota 限界。媒体路径保持 UDP-only;全部 UDP 路径耗尽后明确失败。HTTPS/WSS 仍独立使用 TLS/TCP。
- [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,三档与高级设置初始值统一采用 `balanced`;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,具体降级由浏览器决定;必须读回实际 sender 参数并通过 stats 观测结果、显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。
- [ ] 质量控制:保留 1080p60(8 Mbps)、1080p30(5 Mbps)、720p30(3 Mbps)三档推荐上限,三档与高级设置初始值统一采用 `maintain-resolution`(清晰优先),`balanced`(平衡)与 `maintain-framerate`(流畅优先)保留为显式选择;分享期间可直接换档或应用有界高级设置,不重新选择来源或重建健康连接。Peer-assisted relay 与已配置的 SFU 必须接收同一个严格校验的房间级权威设置,覆盖当前和以后建立的 sender;普通 P2P 信令结构保持不变。任何设置都只是浏览器上限和偏好,具体降级由浏览器决定;必须读回实际 sender 参数并通过 stats 观测结果、显示参数拒绝、改写和持续受限原因。不做自定义动态 FPS、综合评分或周期性全房间调档。
- [ ] 路径隔离双表示:direct/peer 继续使用每条 PeerConnection 独立的 stock WebRTC GCC,不把任何 viewer 反馈合成全房目标。每条 SFU path 从 `HIGH` ceiling 开始;优先用 pinned LiveKit 2.22.0/1.13.5 的标准两层 screen-share simulcast,在 Dynacast off 下常驻 exactly `HIGH+LOW`,让内建 per-subscriber BWE 独立降层和恢复。若该 gate 通过,Screener 不实现媒体层 selector。首 gate 使用 zero-descendant SFU leaf;root-with-children 的自动 downshift 与有界迁移是 default-on 前独立硬门。只有内建选择不满足门槛时才用标准 subscriber quality 显式限制,常驻成本不合格时再测标准 sender 手动启停。`LOW` 停编只是资源优化;active representation/layer 硬上限为二,禁止按 viewer 增长。必须实测实际接收层、硬件编码证据、游戏 FPS/p1 low、CPU/GPU、区间 encode cost、上传和分层 bytes。当前 Web P2P simulcast 缺少 receiver 选 RID 和跨 PeerConnection shared-encode 契约;当前 Web/LiveKit SVC 也缺少 direct/peer 独立选层与可移植硬件保证,且 pinned screen share 强制三层 temporal-only `L1T3`,二者按现契约静态 no-go。全部标准路径失败后才考虑 custom/native dual encode。没有合格 hardware/power-efficient 路径或额外表示破坏游戏/上传预算时 fail closed:保护健康路径并明确显示弱路径降级或不可用。
- [ ] 路径级质量状态:应用的 `HIGH`/`FALLBACK` 非对称窗口只决定拓扑资格和诊断,不命令正常的媒体层切换,也不计算综合分数。SFU BWE downshift 先进入 `suspect`;多个连续窗口的 sender/viewer 关联证据确认 `FALLBACK` 后,应用不得稳定保留该 parent,并把 downstream capacity 降为 0。计划或显式质量 fallback 仅是内建路径失败后的候选:已有 children 时先在 connection/assignment generation guard 下全部迁走,失败或代次过期则保持原 assignment/quality。Autonomous downshift 无法 packet-level 预先迁移,default-on 前必须验收 descendants 的短暂影响。只有更长的稳定恢复窗口和 cooldown 后才能重新声明 relay capacity。Viewer 请求必须鉴权、限频、去重,并由 sender 的 GCC/Transport-CC、实际码率、RTT、丢包、重传、编码限制、协商 codec/profile/parameters 与 viewer 解码、丢帧、冻结证据交叉验证;不能仅凭自报生效,也不得上传 raw SDP、candidate 地址或原始设备/网络标识。
- [ ] 断线恢复:临时网络变化时按不同层级做一次性恢复,而不是把同一动作机械重复三次。`failed` 边先做一次 ICE restart,再重建一次同 parent 的 `RTCPeerConnection`,随后尝试一个不同的合格 peer parent;只有 peer 路径耗尽才进入 SFU。`connecting`/`checking` 先显示不改变拓扑的慢连接状态,硬超时必须由真实移动网络证据确定,不能为了更快使用服务器而过早放弃最终可成功的 P2P。任一步恢复即停止后续动作;所有异步结果受 session、route revision、assignment/connection generation 约束。active SFU 只允许一次 fresh-token 重试,再失败即有界回到 peer baseline 或明确失败,并向 Host 显示不含 URL、地址、token 或原始错误的失败阶段。
@@ -43,6 +43,7 @@
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。合格父节点构成网状候选关系,但实际媒体保持无环且每个 Viewer 只有一个 active upstream;加入、容量变化、掉线和当前边失败触发有界重选,切换重叠必须受现有边预算与当前代媒体证据约束,不做常驻多源分片。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。Web 不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件;每个实际浏览器音频 sender 固定请求并读回 128 kbps ceiling,SFU 音频发布使用同值 preset 且关闭 DTX。该值只是标准 API/SDK 支持的发送上限,实际码率仍以 stats 为准。display capture 未把声道和采样率定义为可移植控制,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
- [ ] SFU 质量设置生命周期:直播中修改档位时,浏览器 sender 的 applied readback、LiveKit `LocalVideoTrack` 保存的 degradation preference、track restart options 与 `LocalTrackPublication` 的 republish options 必须作为同一次串行操作更新;任一步失败恢复旧 sender 与旧 SDK 保存态,旧 publication generation 不得回写当前状态。LiveKit full reconnect/republish 不得恢复初次发布的旧偏好。该修复不改变 codec、码率上限、层数或路由,也不能单独解释即时低帧。
- [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
- [ ] Peer-assisted 媒体路径:分享端与每名普通 Web viewer 都最多两个 child。信令服务维护可从多个合格节点选择 parent 的候选图,但每名 viewer 任一时刻只有一个 active upstream,活动媒体图保持无环;只在新加入、容量释放、节点断开或当前 edge 失败时按深度和服务端加入序号做有界分配或换父,不加入综合评分、主动重平衡、多源媒体或持续优化。每个 viewer 将收到的标准 remote track 通过最多两个新的 `RTCPeerConnection` 转发,因此会逐跳解码和分别重编码;必须测量而不能称为 shared encode。服务端只在受房间数限制的内存状态中保存当前严格校验的质量设置,不写入 SQLite;host 鉴权后先重报设置,在线 viewers 接收更新,后加入者从鉴权快照取得当前值。
- [ ] 原生 shared encode 分享端:后续独立的 Electron/原生分享端必须让每个 active representation 只产生一份兼容编码结果,并在 direct、peer 与 SFU transport 间复用;健康路径选择共享 `HIGH`,最多另有一份弱路径共用的 `LOW`,是否常驻由硬件、游戏和上传门槛决定。每条连接仍保留独立 WebRTC packetization、SSRC、pacing、加密和反馈,且各自消耗上传带宽。浏览器不保证跨 `RTCPeerConnection` 复用编码器,因此当前 Web spike 不能宣称满足该保证。
+2 -2
View File
@@ -22,13 +22,13 @@ export const QUALITY_PROFILES = {
resolution: "1080p",
maxFramerate: 30,
maxBitrate: 5_000_000,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
},
"720p30": {
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
},
} as const satisfies Record<QualityProfileId, QualitySettings>;
+30 -7
View File
@@ -157,8 +157,9 @@ export class SfuPublisher {
video,
profile,
sdk,
() => this.owns(room, generation),
);
if (!this.owns(room, generation)) {
if (!videoConfiguration || !this.owns(room, generation)) {
return false;
}
@@ -292,8 +293,9 @@ export class SfuPublisher {
previousVideo,
profile,
sdk,
() => this.owns(room, generation),
);
if (!this.owns(room, generation)) {
if (!videoConfiguration || !this.owns(room, generation)) {
return false;
}
previousVideo.rawTrack = nextVideoTrack;
@@ -337,8 +339,9 @@ export class SfuPublisher {
previousVideo,
profile,
sdk,
() => this.owns(room, generation),
);
if (!this.owns(room, generation)) {
if (!videoConfiguration || !this.owns(room, generation)) {
return false;
}
this.senderParameters = videoConfiguration.readback;
@@ -377,8 +380,9 @@ export class SfuPublisher {
video,
profile,
sdk,
() => this.owns(room, generation),
);
if (!this.owns(room, generation)) {
if (!videoConfiguration || !this.owns(room, generation)) {
return false;
}
this.profile = profile;
@@ -397,8 +401,9 @@ export class SfuPublisher {
video,
previousProfile,
sdk,
() => this.owns(room, generation),
);
if (!this.owns(room, generation)) {
if (!videoConfiguration || !this.owns(room, generation)) {
return false;
}
this.senderParameters = videoConfiguration.readback;
@@ -553,17 +558,35 @@ async function configurePublishedVideo(
published: PublishedTrack,
profile: QualityProfile,
sdk: LiveKit,
): Promise<PublishedVideoConfiguration> {
ownsPublication: () => boolean,
): Promise<PublishedVideoConfiguration | null> {
const videoTrack = published.publication.videoTrack;
const sender = videoTrack?.sender;
if (!sender) {
throw new Error("SFU video publication has no RTP sender");
}
const previousPreference = videoTrack.publishOptions?.degradationPreference;
await videoTrack.setDegradationPreference(profile.degradationPreference);
if (!ownsPublication()) {
if (previousPreference) {
await videoTrack.setDegradationPreference(previousPreference);
}
return null;
}
const readbacks = await configureTwoLayerVideoSender(sender, profile);
videoTrack.publishOptions = {
if (!ownsPublication()) {
if (previousPreference) {
await videoTrack.setDegradationPreference(previousPreference);
}
return null;
}
const retainedPublishOptions = {
...published.publication.options,
...videoTrack.publishOptions,
...videoPublishOptions(sdk, profile),
};
published.publication.options = retainedPublishOptions;
videoTrack.publishOptions = retainedPublishOptions;
const highWarning = senderParameterWarning(readbacks.high);
const lowWarning = senderParameterWarning(readbacks.low);
return {
-9
View File
@@ -227,15 +227,6 @@ export class HostPeer {
}
await this.connection.setRemoteDescription(payload.description);
await this.flushCandidates();
await this.enqueueSenderMutation(async () => {
const videoSender = this.videoSender;
if (this.disposed || !videoSender) {
return false;
}
// Refresh requested bounds after negotiation; browser adaptation
// remains authoritative.
return this.configureSender(videoSender);
});
} else if (this.connection.remoteDescription) {
await this.connection.addIceCandidate(payload.candidate);
} else if (this.pendingCandidates.length < MAX_PENDING_CANDIDATES) {
+1 -1
View File
@@ -179,7 +179,7 @@ export const DEFAULT_QUALITY_SETTINGS = {
resolution: "1080p",
maxFramerate: 60,
maxBitrate: 8_000_000,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
} as const satisfies QualitySettings;
export const relayDownstreamEdgesSchema = z.union([
+14 -19
View File
@@ -524,39 +524,34 @@ 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 }],
});
});
it("reapplies the selected video profile after the answer", async () => {
it("accepts an answer without reapplying the selected profile", async () => {
const video = createTrack("video", "video");
const audio = createTrack("audio", "audio");
const peer = createPeer(createStream(video, audio));
const peer = createPeer(createStream(video, createTrack("audio", "audio")));
await expect(peer.start()).resolves.toBe(true);
const connection = FakePeerConnection.latest!;
expect(connection.senders[0]?.setParameters).toHaveBeenCalledOnce();
const pendingCandidate = { candidate: "candidate-before-answer" };
await peer.acceptSignal({
kind: "candidate",
connectionId: peer.connectionId,
candidate: pendingCandidate,
});
expect(connection.addedIceCandidates).toEqual([]);
await peer.acceptSignal({
kind: "description",
connectionId: peer.connectionId,
description: { type: "answer", sdp: "test-answer" },
});
expect(connection.remoteDescription).toMatchObject({
type: "answer",
sdp: "test-answer",
});
expect(connection.remoteDescription?.type).toBe("answer");
expect(connection.addedIceCandidates).toEqual([pendingCandidate]);
expect(connection.senders[0]?.track).toBe(video);
expect(connection.senders[0]?.replaceTrack).not.toHaveBeenCalled();
expect(connection.senders[0]?.setParameters).toHaveBeenCalledTimes(2);
expect(
connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
).toMatchObject({
degradationPreference: "balanced",
encodings: [{ maxBitrate: 3_000_000, maxFramerate: 30 }],
});
expect(connection.senders[0]?.setParameters).toHaveBeenCalledOnce();
expect(connection.senders[1]?.setParameters).toHaveBeenCalledOnce();
});
@@ -632,7 +627,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 }],
});
});
+9 -8
View File
@@ -77,10 +77,11 @@ describe("realtime quality controls", () => {
});
});
it("defaults every recommended profile to balanced degradation", () => {
it("defaults every recommended profile to clarity-first degradation", () => {
expect(
Object.values(QUALITY_PROFILES).every(
(profile) => profile.degradationPreference === "balanced",
(profile) =>
profile.degradationPreference === "maintain-resolution",
),
).toBe(true);
});
@@ -108,14 +109,14 @@ describe("realtime quality controls", () => {
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 4 / 3,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
scalabilityMode: null,
},
applied: {
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 4 / 3,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
scalabilityMode: null,
},
mismatches: [],
@@ -145,7 +146,7 @@ describe("realtime quality controls", () => {
const before = { encodings: [{}] } as RTCRtpSendParameters;
const after = {
encodings: [{ maxBitrate: 8_000_000 }],
degradationPreference: "maintain-resolution",
degradationPreference: "balanced",
} as unknown as RTCRtpSendParameters;
const getParameters = vi
.fn<() => RTCRtpSendParameters>()
@@ -185,7 +186,7 @@ describe("realtime quality controls", () => {
scalabilityMode: "L1T2",
},
],
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
} as unknown as RTCRtpSendParameters;
const sender = {
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
@@ -220,7 +221,7 @@ describe("realtime quality controls", () => {
},
{ rid: "high", scalabilityMode: "L1T2" },
],
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
} as unknown as RTCRtpSendParameters;
const sender = {
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
@@ -260,7 +261,7 @@ describe("realtime quality controls", () => {
);
expect(applied).toMatchObject({
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
encodings: [
{
rid: "q",
+1 -1
View File
@@ -31,7 +31,7 @@ const defaultQualitySettings = {
resolution: "1080p",
maxFramerate: 60,
maxBitrate: 8_000_000,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
} as const;
const balancedQualitySettings = {
resolution: "1080p",
+196 -8
View File
@@ -57,6 +57,8 @@ const livekit = vi.hoisted(() => {
class FakeLocalTrack {
currentTrack: MediaStreamTrack;
readonly sender = new FakeSender();
publishOptions?: Record<string, unknown>;
savedDegradationPreference: RTCDegradationPreference | null = null;
constructor(track: MediaStreamTrack) {
this.currentTrack = track;
@@ -67,6 +69,12 @@ const livekit = vi.hoisted(() => {
this.currentTrack = nextTrack;
},
);
readonly setDegradationPreference = vi.fn(
async (preference: RTCDegradationPreference): Promise<void> => {
this.savedDegradationPreference = preference;
},
);
}
class FakeLocalParticipant {
@@ -83,6 +91,7 @@ const livekit = vi.hoisted(() => {
options: Record<string, unknown>,
) => {
const localTrack = new FakeLocalTrack(rawTrack);
localTrack.publishOptions = options;
const simulcastLayers = options.screenShareSimulcastLayers;
const highEncoding = options.screenShareEncoding;
if (
@@ -124,6 +133,13 @@ const livekit = vi.hoisted(() => {
},
);
republishedOptions: Array<Record<string, unknown>> = [];
readonly republishAllTracks = vi.fn(async (): Promise<void> => {
this.republishedOptions = this.publications.map((publication) => ({
...publication.options,
}));
});
readonly unpublishTrack = vi.fn(
async (_track: FakeLocalTrack, _stopOnUnpublish: boolean) => undefined,
);
@@ -275,7 +291,7 @@ const qualityProfile = {
resolution: "1080p",
maxFramerate: 60,
maxBitrate: 8_000_000,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
} as const;
function track(kind: "video" | "audio", id: string): MediaStreamTrack {
@@ -439,7 +455,7 @@ describe("SfuPublisher", () => {
},
}),
],
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
});
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
source: Track.Source.ScreenShareAudio,
@@ -466,14 +482,14 @@ describe("SfuPublisher", () => {
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 1,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
scalabilityMode: null,
},
applied: {
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 1,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
scalabilityMode: null,
},
mismatches: [],
@@ -570,6 +586,151 @@ describe("SfuPublisher", () => {
expect(publisher.getQualityWarning()).toBeNull();
});
it("retains the active profile for LiveKit track restart and republish", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const localTrack = publication.track;
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
}),
).resolves.toBe(true);
expect(localTrack.setDegradationPreference).toHaveBeenLastCalledWith(
"maintain-framerate",
);
expect(
localTrack.setDegradationPreference.mock.invocationCallOrder.at(-1)!,
).toBeLessThan(
localTrack.sender.setParameters.mock.invocationCallOrder.at(-1)!,
);
expect(localTrack.savedDegradationPreference).toBe("maintain-framerate");
expect(localTrack.sender.parameters).toMatchObject({
degradationPreference: "maintain-framerate",
encodings: [
{
rid: "q",
maxBitrate: 750_000,
maxFramerate: 30,
scaleResolutionDownBy: 2,
},
{
rid: "h",
maxBitrate: 3_000_000,
maxFramerate: 30,
scaleResolutionDownBy: 1,
},
],
});
expect(localTrack.publishOptions).toBe(publication.options);
expect(publication.options).toMatchObject({
source: Track.Source.ScreenShare,
backupCodec: false,
videoCodec: "h264",
screenShareEncoding: {
maxBitrate: 3_000_000,
maxFramerate: 30,
},
degradationPreference: "maintain-framerate",
});
await livekit.state.rooms[0].localParticipant.republishAllTracks();
expect(
livekit.state.rooms[0].localParticipant.republishedOptions[0],
).toMatchObject({
screenShareEncoding: {
maxBitrate: 3_000_000,
maxFramerate: 30,
},
degradationPreference: "maintain-framerate",
});
});
it("restores LiveKit retained state when saving a new profile fails", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const localTrack = publication.track;
localTrack.setDegradationPreference.mockRejectedValueOnce(
new Error("preference save failed"),
);
await expect(
publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
}),
).resolves.toBe(false);
expect(localTrack.setDegradationPreference).toHaveBeenLastCalledWith(
"maintain-resolution",
);
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(localTrack.publishOptions).toBe(publication.options);
expect(publication.options).toMatchObject({
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
expect(publisher.getSenderParameters()?.applied).toMatchObject({
maxBitrate: 8_000_000,
maxFramerate: 60,
degradationPreference: "maintain-resolution",
});
expect(publisher.getQualityWarning()).toContain("preference save failed");
});
it("does not retain an update from a disconnected publisher generation", 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;
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const gate = deferred();
localTrack.setDegradationPreference.mockImplementationOnce(
async (preference) => {
await gate.promise;
localTrack.savedDegradationPreference = preference;
},
);
const updating = publisher.updateProfile({
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "maintain-framerate",
});
await vi.waitFor(() =>
expect(localTrack.setDegradationPreference).toHaveBeenCalledTimes(2),
);
await publisher.disconnect();
gate.resolve();
await expect(updating).resolves.toBe(false);
expect(publisher.getSenderParameters()).toBeNull();
expect(publisher.getQualityWarning()).toBeNull();
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(localTrack.sender.setParameters).toHaveBeenCalledTimes(1);
expect(publication.options).toMatchObject({
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
});
it("retains a visible warning when the SFU sender rewrites a parameter", async () => {
const publisher = new SfuPublisher();
await publisher.connect(connection);
@@ -637,8 +798,9 @@ describe("SfuPublisher", () => {
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;
const publication = livekit.state.rooms[0].localParticipant.publications[0];
const localTrack = publication.track;
const sender = localTrack.sender;
sender.setParameters.mockRejectedValueOnce(new Error("unsupported"));
await expect(
@@ -646,10 +808,36 @@ describe("SfuPublisher", () => {
resolution: "720p",
maxFramerate: 30,
maxBitrate: 3_000_000,
degradationPreference: "balanced",
degradationPreference: "maintain-framerate",
}),
).resolves.toBe(false);
expect(localTrack.setDegradationPreference).toHaveBeenLastCalledWith(
"maintain-resolution",
);
expect(localTrack.savedDegradationPreference).toBe("maintain-resolution");
expect(publication.options).toMatchObject({
screenShareEncoding: {
maxBitrate: 8_000_000,
maxFramerate: 60,
},
degradationPreference: "maintain-resolution",
});
expect(sender.parameters).toMatchObject({
degradationPreference: "maintain-resolution",
encodings: [
expect.objectContaining({
rid: "q",
maxBitrate: 2_000_000,
maxFramerate: 60,
}),
expect.objectContaining({
rid: "h",
maxBitrate: 8_000_000,
maxFramerate: 60,
}),
],
});
expect(publisher.getQualityWarning()).toBe(
"应用 SFU 发送参数失败:unsupported",
);
@@ -746,7 +934,7 @@ describe("SfuPublisher", () => {
expect(publisher.getSenderParameters()?.requested).toMatchObject({
maxBitrate: 8_000_000,
maxFramerate: 60,
degradationPreference: "balanced",
degradationPreference: "maintain-resolution",
});
});