From d39f4359cded7ebde15ef1f80de70c4f1d308241 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:34:19 +0800 Subject: [PATCH] fix(sfu): publish a single high representation (#248) --- docs/project-memory.md | 4 +- docs/research/realtime-quality-adaptation.md | 13 +- docs/session-handoff.md | 3 +- docs/status.md | 6 +- docs/todo.md | 2 +- docs/verification-status.md | 4 +- src/client/media/quality.ts | 70 +------ src/client/sfu/publisher.ts | 43 +--- src/client/sfu/subscriber.ts | 5 - tests/quality.test.ts | 89 --------- tests/sfu-client.test.ts | 194 ++----------------- 11 files changed, 46 insertions(+), 387 deletions(-) diff --git a/docs/project-memory.md b/docs/project-memory.md index 4ee0fd9..e43a4e5 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -33,14 +33,14 @@ Last updated: 2026-08-25 - Current source and production admit up to `20` Viewers and implement the uniform `1/2/3` endpoint cap, exact prepare candidate, first-decoded-frame commit, strict rollback revision, decoded-progress stall, a two-stage direct/SFU operation deadline, and one event-driven room controller. A short-lived exact-generation decoded-frame observer proves only the pending candidate inside the existing total deadline; the periodic stats sampler remains responsible for active-path diagnostics and stalls. Ordinary peer ICE is STUN-only and the sole application fallback is SFU/UDP; stale TURN environment keys fail startup. SFU admission uses exact publication and Viewer-subscription handles with explicit no-default ingress/egress capacities and a listener-fenced dedicated LiveKit owner. Browser SFU publisher/subscriber connects use fresh empty ICE-server options; the owner physically verified deployed SFU fallback media after release `c4962f5`, closing the prior core-route hold. - Source and production use random four-digit memory rooms, dormant leases, exact-Host resume, restart loss, local preference replay, orthogonal grants/code entry, and no SQLite runtime. V12 uses a 22-character room-incarnation grant, `ROOM_NOT_FOUND`, no diagnostic-file export, and a compact UI showing the complete shorter URL. - Exact production artifacts, services, ports, and any change-scoped infrastructure recovery boundary are owned by [deployment](./deployment.md) and indexed by [status](./status.md). -- Source and production require v12, fixed VP8, and no video hint. Their SFU publisher still sends ordered `q,h` simulcast and is not yet aligned with accepted single `HIGH`. Current Pause/Resume, live audio ceilings, exact-sender metrics, relay/SFU audio continuity, typed Viewer presentation, and quality profiles match the product truths above. +- Source and production require v12, fixed VP8, and no video hint. Current source publishes exactly one SFU `HIGH` encoding without simulcast or a subscriber layer selector; production still sends ordered `q,h` until the next application cutover. Current Pause/Resume, live audio ceilings, exact-sender metrics, relay/SFU audio continuity, typed Viewer presentation, and quality profiles match the product truths above. - Browser SFU subscribers reconcile eligible Host screen publications across connect/activate event ordering, participant/track arrival, and reconnect. Production LiveKit logs only warn/error, and the application service has a bounded same-host LiveKit readiness preflight. - Exact `main@bf32859` passed TypeScript, 613 Web tests, both builds, hygiene, review, and production postflight. Chrome 151 closed direct/browser-relay VP8 cadence and active-audio ceilings; its SFU A/B rejected always-active `LOW` without claiming a deployed single representation, public SFU 60 fps, or real-game synchronization. Exact metrics live in [verification status](./verification-status.md). - Chrome loopback, cap2/cap3, short resource, recovery, MBB, and A/V fixtures are bounded evidence only. They do not establish quality, resource limits, or product policy. ## Current Priority -1. Implement, deploy, and physically verify the accepted single-`HIGH` Browser SFU publication; leave direct, browser-relay, content-hint, and routing behavior unchanged. +1. Deploy and physically verify the current single-`HIGH` Browser SFU publication; leave direct, browser-relay, content-hint, and routing behavior unchanged. 2. Then finish real-network route, recovery, screen-audio and real-game A/V acceptance. Browser VP8 hardware acceleration and mobile Viewer lifecycle remain Accepted Later; Native/executable, distribution, and repository-wide simplification remain later phases. The desktop Host background/minimized report is deferred until it reproduces on current production with a real game and synchronized media plus CPU/GPU evidence. The bounded current-Chrome screening found no immediate Host-page lifecycle drop, and removal of the old harmful `contentHint = "motion"` remains a plausible confound rather than a proven background fix. Mobile Viewer playback and assigned-relay survival remain a separate lifecycle gate. diff --git a/docs/research/realtime-quality-adaptation.md b/docs/research/realtime-quality-adaptation.md index 66d65d5..34e62d1 100644 --- a/docs/research/realtime-quality-adaptation.md +++ b/docs/research/realtime-quality-adaptation.md @@ -226,7 +226,7 @@ when a sender is installed; `LocalVideoTrack.publishOptions` drives encoding recomputation after a track restart; and `LocalTrackPublication.options` is the input to `republishAllTracks()`. Current source keeps those three owners aligned through the existing publisher operation queue and rollback: call the SDK -preference API to update its saved state, configure/read back the current `q,h` +preference API to update its saved state, configure/read back the exact single 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 @@ -408,7 +408,7 @@ time deltas, `remoteId` linkage, and the selected path reached through that RTP stream's transport. Source replacement blocks sampling and invalidates in-flight generations. Direct/relay edges bind that evidence to the current track and PeerConnection. The Host SFU publisher also samples only its -active LiveKit sender's `h` representation and binds the result again to the +exact active LiveKit sender and binds the result again to the current publication generation; replacement, profile reset, retirement, disconnect, and authoritative resync clear the old identity before another sample can appear. The Host details UI renders that source once as `SFU 发送`, @@ -443,11 +443,10 @@ one unambiguous encoding. Current quality settings do not request a mode, so the requested value remains null and a browser-reported default is not called a mismatch; multiple encodings remain unknown. Inbound stats provide no current standard `scalabilityMode` source, so C does not carry a null-only placeholder. -Current source and production still configure the ordered `q,h` publication, -but the exact-production gate above rejected it because the active lower -representation reduced `HIGH`. ADR-0007 now accepts one Browser SFU `HIGH`; -source implementation and the exact single-representation production gate are -the next scoped work. +The exact-production gate above rejected its ordered `q,h` publication because +the active lower representation reduced `HIGH`. Current source now implements +ADR-0007's one Browser SFU `HIGH`; production remains `q,h` until the exact +single-representation deployment gate passes. Official W3C text checked 2026-08-19 defines names ending in `Id` as stats-object references. In particular, outbound [`mediaSourceId`](https://www.w3.org/TR/webrtc-stats/#dom-rtcoutboundrtpstreamstats-mediasourceid) diff --git a/docs/session-handoff.md b/docs/session-handoff.md index 9cc9420..5f3803c 100644 --- a/docs/session-handoff.md +++ b/docs/session-handoff.md @@ -58,7 +58,8 @@ Current high-signal boundary: three sender ceilings. An exact-production VP8 gate sustained about 59.5 fps on direct and Browser-relay paths and rejected the current SFU `q,h` publication because active `LOW` reduced `HIGH`. ADR-0007 accepts one Browser - SFU `HIGH`; source and production remain `q,h` until the next scoped change. + SFU `HIGH`; current source implements that single representation, while + production remains `q,h` until the next application cutover and physical gate. - Ordinary browser ICE owns direct reachability. Do not build port prediction, guessed candidates, NAT classification, TCP probes, quality scores, or quality-driven reparenting. Current-path quality is diagnostic; a healthy diff --git a/docs/status.md b/docs/status.md index fd2f189..0fe7532 100644 --- a/docs/status.md +++ b/docs/status.md @@ -22,14 +22,14 @@ This is the current execution index. Git history owns completed timelines; [veri - One event-driven controller owns the committed graph and at most one room-serial child operation. It uses one deterministic candidate list/cursor and one total direct-then-SFU deadline; exact admission and physical resources remain charged through drain, and the pending candidate commits only after an exact-generation decoded-frame proof. A short-lived 100 ms stats observer proves only that candidate inside the existing deadline, while the 2-second sampler remains responsible for active-path diagnostics and decoded-frame stalls. Healthy edges remain sticky and current-path quality does not authorize reparenting. - Ordinary peer ICE is STUN-only and the only application fallback is the dedicated LiveKit SFU over UDP. SFU generations use explicit no-default ingress/egress capacities, exact `reserved | committed | draining` accounting, delete-plus-absence release, one Host publication, and exact Viewer subscription handles. No Screener TURN, ICE/TCP, media TCP, or TLS-relayed media route exists. - Current Browser SFU publisher and subscriber connects supply fresh `autoSubscribe: false` plus `rtcConfig: { iceServers: [] }` options. Ordinary peers retain deployment STUN; LiveKit signaling candidates, server-side public-IP discovery, route deadlines, ports, and retry behavior are unchanged. -- Current source leaves video `contentHint` unset and keeps audio `contentHint = "music"`. Browser direct and browser-relay offers contain VP8 as their only media codec, and SFU publication explicitly uses VP8 with no backup codec. Codec UI, quality state, wire fields, fallback media codecs, and share-lifetime codec branches are absent. The SFU publisher still configures ordered `q,h` simulcast and is therefore out of alignment with ADR-0007's accepted single-`HIGH` representation until the next scoped source change. Live 64/128/256 kbps audio-ceiling mutation remains unchanged; ordinary Host and SFU publisher metrics select the exact current audio sender, relays reconcile audio-track additions and removals on the persistent stream, and an SFU reconnect reacquires the current publication/sender or fails the route closed. +- Current source leaves video `contentHint` unset and keeps audio `contentHint = "music"`. Browser direct and browser-relay offers contain VP8 as their only media codec, and SFU publication uses exactly one VP8 `HIGH` encoding with `simulcast: false`, no backup codec, no subscriber quality selector, and explicit `dynacast: false`. Codec UI, quality state, wire fields, fallback media codecs, and share-lifetime codec branches are absent. Live 64/128/256 kbps audio-ceiling mutation remains unchanged; ordinary Host and SFU publisher metrics select the exact current sender, relays reconcile audio-track additions and removals on the persistent stream, and an SFU reconnect reacquires the current publication/sender or fails the route closed. - Current source implements typed first-frame Viewer presentation and recovery, Chinese user-facing route/access failures, local connection details without a diagnostic-file export, authenticated Host-only on-demand route snapshots for automatic acceptance, explicit missing-room presentation, and responsive entry controls. Host invitation controls remain independent from two-state room-code entry. - Recommended quality remains exactly `1080p60`, `1080p30`, and `720p30`; current source defaults to `1080p30`. Advanced resolution adds `480p` as `854x480` without adding a fourth preset, and advanced FPS and bitrate remain independent. -- Exact `main@bf32859` passed repository hygiene, TypeScript, all 613 Web tests in 44 files, both production builds, focused independent review, and the production artifact/service postflight above. A Chrome 151 production gate then exercised two direct children, one browser-relay child, one SFU subscription, all three audio ceilings, and live source replacement with progressing audio/video. A separate exact-production VP8 gate sustained about 59.5 fps on direct and browser-relay paths, while its SFU A/B proved that the always-active `LOW` materially reduced `HIGH`; exact applicability remains in [verification status](./verification-status.md). +- Current source passed repository hygiene, TypeScript, all 607 Web tests in 44 files, and both production builds. Exact production `bf32859` separately passed its recorded source and postflight gates. A Chrome 151 production gate exercised two direct children, one browser-relay child, one SFU subscription, all three audio ceilings, and live source replacement with progressing audio/video. A separate exact-production VP8 gate sustained about 59.5 fps on direct and browser-relay paths, while its SFU A/B proved that the always-active `LOW` materially reduced `HIGH`; the new single-representation source still requires exact deployed proof. ## Current Milestone -1. Align Browser SFU publication with ADR-0007's accepted single-`HIGH` VP8 representation, deploy the exact artifact, and physically verify one outbound video encoding with progressing publisher/subscriber media. Direct and browser-relay VP8/no-hint performance is already closed by the exact-production gate. +1. Deploy the current single-`HIGH` Browser SFU source, then physically verify exactly one outbound video encoding with progressing publisher/subscriber media. Direct and browser-relay VP8/no-hint performance is already closed by the exact-production gate. 2. Finish representative ICE/STUN/SFU acceptance, including route recovery, screen-audio continuity and real-game A/V sync on mobile and heterogeneous networks. Browser port prediction, NAT classification, TCP probing, fake page keepalive, and quality-driven reparenting remain outside the accepted model. ## Active Boundaries diff --git a/docs/todo.md b/docs/todo.md index 6709c84..eab1925 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -6,7 +6,7 @@ Only items in **Now** are executable after their stated decision gate. A branch ## Now -1. **Align Browser SFU publication with the accepted single-`HIGH` verdict.** Exact production Chrome 151 closed the intrinsic VP8/no-hint shortfall on direct and browser-relay paths, but proved that the always-active SFU `LOW` materially reduces `HIGH` on the measured public ingress. Replace the current ordered `q,h` publication with one VP8 `HIGH` representation, remove only the now-unowned two-layer helpers and assertions, deploy the exact artifact, and physically verify one outbound video encoding plus progressing publisher/subscriber frames over LiveKit UDP. Do not change direct/browser-relay media, `contentHint`, route ordering, deadlines, or add a bitrate controller. +1. **Deploy and prove the current single-`HIGH` Browser SFU publication.** Exact production Chrome 151 closed the intrinsic VP8/no-hint shortfall on direct and browser-relay paths, but proved that the always-active SFU `LOW` materially reduces `HIGH` on the measured public ingress. Current source now uses one VP8 `HIGH` encoding and removes only the unowned two-layer helpers and assertions. Deploy the exact artifact and physically verify exactly one outbound video encoding, no RID or second representation, and progressing publisher/subscriber frames over LiveKit UDP. Do not change direct/browser-relay media, `contentHint`, route ordering, deadlines, or add a bitrate controller. 2. **Finish real-network route acceptance.** Measure standard ICE/STUN direct paths, peer relay, SFU ingress/subscription, exact first-frame commit and rollback, relay-ingress repair with subtree retention, disconnect/capacity drain, Pause/Resume, screen-audio continuity and real-game A/V sync, and bounded explicit failure across representative IPv4/IPv6 NATs and mobile networks. Include an all-UDP-blocked case to establish the current failure boundary. Do not add port prediction, NAT classification, TCP probes, guessed candidates, route scores, or quality-driven reparenting. ## Accepted Later Roadmap diff --git a/docs/verification-status.md b/docs/verification-status.md index a87d76f..f54c0c5 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -35,11 +35,11 @@ Capacity and routing entries below describe the exact tested source or release o ## Open Proof Boundaries - Routing: exact v10 local direct loopback remains historical evidence for admission-20 initial ingress and exact-candidate first-frame commit timing on that source. Current v12 production still needs route rollback, direct-stage exhaustion and SFU admission, relay-ingress reparent with subtree retention, disconnect/effective-capacity drain, pause/resume, and bounded failure on real browser/media paths. Silent control partitions may wait 30 to 60 seconds for heartbeat detection before the default five-second grace; a non-paused decoded-frame stall may invalidate only that exact child edge. -- Quality: exact-production direct and Browser-relay VP8/no-hint cadence is closed. The current dual-representation SFU publication failed ADR-0007's quality gate; source/deployment alignment and exact single-representation publisher/subscriber evidence remain open. Startup ramp is stock BWE behavior and does not authorize an SDP or application bitrate workaround. +- Quality: exact-production direct and Browser-relay VP8/no-hint cadence is closed. Current source implements ADR-0007's single-`HIGH` SFU publication; deployment and exact single-representation publisher/subscriber evidence remain open. Startup ramp is stock BWE behavior and does not authorize an SDP or application bitrate workaround. - Host background: deferred until the report reproduces on exact production with a continuously changing real game or replay, a separate direct wired Viewer, synchronized capture/outbound/inbound plus CPU/GPU evidence, and repeated focused/unfocused/hidden/minimized windows. The current-Chrome Browser `getDisplayMedia()` WPF-window screening did not observe an immediate Host-page media drop, and the old harmful `contentHint = "motion"` release remains a plausible confound rather than a proven background cause. Current evidence authorizes no keepalive, Wake Lock, timer, codec switch, route change, or Native fallback. - Relay resources: current source and production expose the authenticated `1/2/3` endpoint-cap boundary, and retained v10 local loopback evidence covers 20 Viewers at cap `2` only for that source. CPU/GPU/upload and heterogeneous-network measurements remain diagnostics, not authority to change the contract. - Room scale: current source and production admit 20 Viewers plus one Host, and the retained v10 local Browser convergence gate covers that bound on direct loopback. A real public-network 1:20 session, quality, and sustained resource matrix remain open. -- SFU: production uses one Host publication with exact Viewer subscriptions and no fixed root-count or room-wide lease rule. Browser SFU PCs use the accepted empty-external-ICE-server rule and owner physical media proof closed the reported connection hold. Current production still publishes `q,h`; the accepted single-`HIGH` source/deployment change and its exact physical gate remain open. Real LiveKit lifecycle/JWT replay, resource use, SFU-fed first-child behavior, and broader heterogeneous-network acceptance remain open. +- SFU: production uses one Host publication with exact Viewer subscriptions and no fixed root-count or room-wide lease rule. Browser SFU PCs use the accepted empty-external-ICE-server rule and owner physical media proof closed the reported connection hold. Current source publishes one `HIGH` encoding without simulcast or a subscriber layer selector, while production still publishes `q,h`; exact deployment and physical proof remain open. Real LiveKit lifecycle/JWT replay, resource use, SFU-fed first-child behavior, and broader heterogeneous-network acceptance remain open. - Audio: current v12 source and production select the exact active ordinary Host or SFU publisher audio sender, reset samples across track changes or absence, keep relay children synchronized with persistent-stream audio changes, and reacquire exact SFU publications/senders after reconnect. The reported active-source `1 kbps` observation and all three live ceilings are physically closed on current Chrome/production across direct, browser-relay and SFU. Audible game quality, heterogeneous-network changes and real-game A/V synchronization remain part of route acceptance; a muted/idle Opus track may still legitimately remain near 1 kbps. - Observability: current v12 source and production keep local connection details but no Host/Viewer diagnostic downloads or Browser file-export plumbing. The Host-only bounded snapshot stays for automatic acceptance. Chrome 151 supplied selected candidate endpoints and ordinary-route A/V playout timestamps in the current gate, but omitted the SFU A/V timestamp. Target-browser availability and actual values remain open for codec/fmtp and encoder implementation/power efficiency, media-source and encoded FPS, and broader jitter/concealment cases. All retained fields diagnose and have no recovery authority; they do not by themselves prove hardware encode, synchronization quality, or route quality. - Mobile: Android/iOS remain Viewer-only. Autoplay, rotation, iOS lock-screen/page reclamation, background reconnection, and network changes need device evidence. AirPlay/system mirroring is Viewer-local output, not a portable in-app WebRTC output contract. diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts index 60bad27..2e20cec 100644 --- a/src/client/media/quality.ts +++ b/src/client/media/quality.ts @@ -93,19 +93,12 @@ export interface VideoSenderParameterReadback { mismatches: Array; } -export interface TwoLayerVideoSenderParameterReadback { - low: VideoSenderParameterReadback; - high: VideoSenderParameterReadback; -} - export interface AudioSenderParameterReadback { requestedMaxBitrate: number; appliedMaxBitrate: number | null; mismatch: boolean; } -export const SCREEN_SHARE_LOW_SCALE = 2; - export function resolveScreenAudioQuality( quality: ScreenAudioQuality | undefined, ): ScreenAudioQuality { @@ -118,10 +111,6 @@ export function screenAudioBitrate( return SCREEN_AUDIO_BITRATES[resolveScreenAudioQuality(quality)]; } -export function screenShareLowBitrate(profile: QualityProfile): number { - return Math.max(150_000, Math.floor(profile.maxBitrate / 4)); -} - export function qualitySettingsEqual( left: QualitySettings, right: QualitySettings, @@ -257,9 +246,8 @@ function requestedScaleResolutionDownBy( function readVideoSenderParameters( parameters: RTCRtpSendParameters, includeAppliedScalabilityMode: boolean, - encodingIndex = 0, ): VideoSenderParameterValues { - const encoding = parameters.encodings[encodingIndex]; + const encoding = parameters.encodings[0]; const scalabilityMode = ( encoding as | (RTCRtpEncodingParameters & { scalabilityMode?: unknown }) @@ -290,21 +278,6 @@ function senderParameterReadback( return { requested, applied, mismatches }; } -function requiredTwoLayerEncodingIndexes( - parameters: RTCRtpSendParameters, -): { low: number; high: number } { - if (parameters.encodings.length !== 2) { - throw new Error("SFU simulcast requires exactly two video encodings"); - } - if ( - parameters.encodings[0]?.rid !== "q" || - parameters.encodings[1]?.rid !== "h" - ) { - throw new Error("SFU simulcast requires ordered q and h video encodings"); - } - return { low: 0, high: 1 }; -} - function sameParameter( key: keyof VideoSenderParameterValues, requested: number | string | null, @@ -376,47 +349,6 @@ export function audioSenderParameterWarning( : `浏览器将音频码率上限改写为 ${Math.round(readback.appliedMaxBitrate / 1_000)} kbps`; } -export async function configureTwoLayerVideoSender( - sender: RTCRtpSender, - profile: QualityProfile, -): Promise { - const parameters = sender.getParameters(); - const indexes = requiredTwoLayerEncodingIndexes(parameters); - const highScale = requestedScaleResolutionDownBy(sender, profile); - const lowEncoding = parameters.encodings[indexes.low]!; - const highEncoding = parameters.encodings[indexes.high]!; - - lowEncoding.maxBitrate = screenShareLowBitrate(profile); - lowEncoding.maxFramerate = profile.maxFramerate; - lowEncoding.scaleResolutionDownBy = highScale * SCREEN_SHARE_LOW_SCALE; - highEncoding.maxBitrate = profile.maxBitrate; - highEncoding.maxFramerate = profile.maxFramerate; - highEncoding.scaleResolutionDownBy = highScale; - parameters.degradationPreference = profile.degradationPreference; - - const requestedLow = readVideoSenderParameters(parameters, false, indexes.low); - const requestedHigh = readVideoSenderParameters(parameters, false, indexes.high); - await sender.setParameters(parameters); - - const appliedParameters = sender.getParameters(); - const appliedIndexes = requiredTwoLayerEncodingIndexes(appliedParameters); - const appliedLow = readVideoSenderParameters( - appliedParameters, - false, - appliedIndexes.low, - ); - const appliedHigh = readVideoSenderParameters( - appliedParameters, - false, - appliedIndexes.high, - ); - - return { - low: senderParameterReadback(requestedLow, appliedLow), - high: senderParameterReadback(requestedHigh, appliedHigh), - }; -} - const PARAMETER_LABELS = { maxBitrate: "码率上限", maxFramerate: "帧率上限", diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts index 5324504..c291fd7 100644 --- a/src/client/sfu/publisher.ts +++ b/src/client/sfu/publisher.ts @@ -8,13 +8,10 @@ import type { import { audioSenderParameterWarning, configureScreenAudioSender, - configureTwoLayerVideoSender, - QUALITY_RESOLUTIONS, + configureVideoSender, resolveScreenAudioQuality, screenAudioQualityEqual, - SCREEN_SHARE_LOW_SCALE, screenAudioBitrate, - screenShareLowBitrate, senderParameterWarning, videoQualitySettingsEqual, type AudioSenderParameterReadback, @@ -191,7 +188,7 @@ export class SfuPublisher { room, videoTrack, sdk.Track.Source.ScreenShare, - videoPublishOptions(sdk, profile), + videoPublishOptions(profile), ); if (!this.owns(room, generation)) { return false; @@ -200,7 +197,6 @@ export class SfuPublisher { const videoConfiguration = await configurePublishedVideo( video, profile, - sdk, () => this.owns(room, generation), ); if (!videoConfiguration || !this.owns(room, generation)) { @@ -396,7 +392,6 @@ export class SfuPublisher { const videoConfiguration = await configurePublishedVideo( previousVideo, profile, - sdk, () => this.owns(room, generation), ); if (!videoConfiguration || !this.owns(room, generation)) { @@ -450,7 +445,6 @@ export class SfuPublisher { const videoConfiguration = await configurePublishedVideo( previousVideo, previousVideoProfile, - sdk, () => this.owns(room, generation), ); if (!videoConfiguration || !this.owns(room, generation)) { @@ -537,7 +531,6 @@ export class SfuPublisher { const configured = await configurePublishedVideo( video, profile, - sdk, () => this.owns(room, generation) && requestedRevision === this.profileRevision && @@ -569,7 +562,6 @@ export class SfuPublisher { const rolledBack = await configurePublishedVideo( video, previousVideoProfile, - sdk, () => this.owns(room, generation) && requestedRevision === this.profileRevision && @@ -1018,7 +1010,6 @@ export class SfuPublisher { identity.accumulator, { trackIdentifier: identity.videoTrack.id, - rid: "h", audioTrackIdentifier: identity.audioTrack?.id ?? null, }, ), @@ -1091,7 +1082,6 @@ async function unpublishTrack(room: Room, published: PublishedTrack): Promise boolean, ): Promise { const videoTrack = published.publication.videoTrack; @@ -1107,7 +1097,7 @@ async function configurePublishedVideo( } return null; } - const readbacks = await configureTwoLayerVideoSender(sender, profile); + const readback = await configureVideoSender(sender, profile); if (!ownsPublication()) { if (previousPreference) { await videoTrack.setDegradationPreference(previousPreference); @@ -1117,18 +1107,13 @@ async function configurePublishedVideo( const retainedPublishOptions = { ...published.publication.options, ...videoTrack.publishOptions, - ...videoPublishOptions(sdk, profile), + ...videoPublishOptions(profile), }; published.publication.options = retainedPublishOptions; videoTrack.publishOptions = retainedPublishOptions; - const highWarning = senderParameterWarning(readbacks.high); - const lowWarning = senderParameterWarning(readbacks.low); return { - readback: readbacks.high, - warning: mergeQualityWarnings( - highWarning, - lowWarning ? `低档表示:${lowWarning}` : null, - ), + readback, + warning: senderParameterWarning(readback), }; } @@ -1205,27 +1190,15 @@ function mergeQualityWarnings(...warnings: Array): string | null return present.length > 0 ? present.join(";") : null; } -function videoPublishOptions( - sdk: LiveKit, - profile: QualityProfile, -): TrackPublishOptions { - const highResolution = QUALITY_RESOLUTIONS[profile.resolution]; +function videoPublishOptions(profile: QualityProfile): TrackPublishOptions { return { backupCodec: false, videoCodec: "vp8", - simulcast: true, + simulcast: false, screenShareEncoding: { maxBitrate: profile.maxBitrate, maxFramerate: profile.maxFramerate, }, - screenShareSimulcastLayers: [ - new sdk.VideoPreset( - Math.floor(highResolution.width / SCREEN_SHARE_LOW_SCALE), - Math.floor(highResolution.height / SCREEN_SHARE_LOW_SCALE), - screenShareLowBitrate(profile), - profile.maxFramerate, - ), - ], degradationPreference: profile.degradationPreference, }; } diff --git a/src/client/sfu/subscriber.ts b/src/client/sfu/subscriber.ts index 430c4dc..3e6d03c 100644 --- a/src/client/sfu/subscriber.ts +++ b/src/client/sfu/subscriber.ts @@ -313,11 +313,6 @@ export class SfuSubscriber { } this.desiredTrackSids.add(publication.trackSid); publication.setSubscribed(true); - if (publication.source === sdk.Track.Source.ScreenShare) { - // HIGH is a ceiling. LiveKit's per-subscriber BWE may still forward LOW - // while this path is constrained and return to HIGH after recovery. - publication.setVideoQuality(sdk.VideoQuality.HIGH); - } } private rememberHostParticipant( diff --git a/tests/quality.test.ts b/tests/quality.test.ts index a451bea..9e93887 100644 --- a/tests/quality.test.ts +++ b/tests/quality.test.ts @@ -5,14 +5,12 @@ import { applyCaptureProfile, captureDisplay, configureScreenAudioSender, - configureTwoLayerVideoSender, configureVideoSender, matchingQualityProfileId, QUALITY_PROFILES, QUALITY_RESOLUTIONS, qualitySettingsEqual, SCREEN_AUDIO_BITRATES, - screenShareLowBitrate, senderParameterWarning, setMediaPaused, } from "../src/client/media/quality.ts"; @@ -353,93 +351,6 @@ describe("realtime quality controls", () => { expect(readback.mismatches).not.toContain("scalabilityMode"); }); - it("configures ordered LOW and HIGH simulcast encodings without flattening LOW", async () => { - let applied = { - encodings: [{ rid: "q" }, { rid: "h" }], - } as RTCRtpSendParameters; - const sender = { - track: { - getSettings: () => ({ width: 2560, height: 1440 }), - }, - getParameters: () => applied, - setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => { - applied = parameters; - }), - } as unknown as RTCRtpSender; - - const readbacks = await configureTwoLayerVideoSender( - sender, - QUALITY_PROFILES["1080p60"], - ); - - expect(applied).toMatchObject({ - degradationPreference: "balanced", - encodings: [ - { - rid: "q", - maxBitrate: 2_000_000, - maxFramerate: 60, - scaleResolutionDownBy: 8 / 3, - }, - { - rid: "h", - maxBitrate: 8_000_000, - maxFramerate: 60, - scaleResolutionDownBy: 4 / 3, - }, - ], - }); - expect(readbacks.low.requested.maxBitrate).toBe(2_000_000); - expect(readbacks.high.requested.maxBitrate).toBe(8_000_000); - expect(readbacks.low.mismatches).toEqual([]); - expect(readbacks.high.mismatches).toEqual([]); - }); - - it("keeps the standard LOW bitrate floor for small custom HIGH ceilings", () => { - expect( - screenShareLowBitrate({ - resolution: "720p", - maxFramerate: 30, - maxBitrate: 400_000, - degradationPreference: "balanced", - }), - ).toBe(150_000); - }); - - it.each([ - { - label: "drops HIGH", - after: { encodings: [{ rid: "q" }] }, - message: "exactly two video encodings", - }, - { - label: "reorders the RIDs", - after: { encodings: [{ rid: "h" }, { rid: "q" }] }, - message: "ordered q and h video encodings", - }, - { - label: "adds a third RID", - after: { encodings: [{ rid: "q" }, { rid: "h" }, { rid: "f" }] }, - message: "exactly two video encodings", - }, - ])("fails closed when sender readback $label", async ({ after, message }) => { - const before = { - encodings: [{ rid: "q" }, { rid: "h" }], - } as RTCRtpSendParameters; - const sender = { - track: { getSettings: () => ({ width: 1920, height: 1080 }) }, - getParameters: vi - .fn<() => RTCRtpSendParameters>() - .mockReturnValueOnce(before) - .mockReturnValue(after as RTCRtpSendParameters), - setParameters: vi.fn(async () => undefined), - } as unknown as RTCRtpSender; - - await expect( - configureTwoLayerVideoSender(sender, QUALITY_PROFILES["1080p60"]), - ).rejects.toThrow(message); - }); - it("pauses and resumes the shared video and audio tracks together", () => { const { stream, videoTrack, audioTrack } = createVideoStream(); diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts index 3d9cc04..8d4dfcb 100644 --- a/tests/sfu-client.test.ts +++ b/tests/sfu-client.test.ts @@ -8,30 +8,6 @@ import { mergeStatsReports } from "../src/client/webrtc/stats.ts"; type EventHandler = (...args: unknown[]) => void; const livekit = vi.hoisted(() => { - class FakeVideoPreset { - readonly encoding: { - maxBitrate: number; - maxFramerate?: number; - }; - - constructor( - readonly width: number, - readonly height: number, - maxBitrate: number, - maxFramerate?: number, - ) { - this.encoding = { maxBitrate, maxFramerate }; - } - - get resolution(): { width: number; height: number; frameRate?: number } { - return { - width: this.width, - height: this.height, - frameRate: this.encoding.maxFramerate, - }; - } - } - class FakeSender { track: MediaStreamTrack; failNextSetParameters = false; @@ -131,32 +107,21 @@ const livekit = vi.hoisted(() => { ) => { const localTrack = new FakeLocalTrack(rawTrack); localTrack.publishOptions = options; - const simulcastLayers = options.screenShareSimulcastLayers; - const highEncoding = options.screenShareEncoding; + const screenShareEncoding = options.screenShareEncoding; if ( rawTrack.kind === "video" && - options.simulcast === true && - Array.isArray(simulcastLayers) && - simulcastLayers.length === 1 && - simulcastLayers[0] instanceof FakeVideoPreset && - typeof highEncoding === "object" && - highEncoding !== null + options.simulcast === false && + typeof screenShareEncoding === "object" && + screenShareEncoding !== null ) { - const high = highEncoding as { + const encoding = screenShareEncoding as { maxBitrate?: number; maxFramerate?: number; }; localTrack.sender.parameters.encodings = [ { - rid: "q", - maxBitrate: simulcastLayers[0].encoding.maxBitrate, - maxFramerate: simulcastLayers[0].encoding.maxFramerate, - scaleResolutionDownBy: 2, - }, - { - rid: "h", - maxBitrate: high.maxBitrate, - maxFramerate: high.maxFramerate, + maxBitrate: encoding.maxBitrate, + maxFramerate: encoding.maxFramerate, scaleResolutionDownBy: 1, }, ]; @@ -205,15 +170,9 @@ const livekit = vi.hoisted(() => { class FakeRemotePublication { subscribed = false; - requestedVideoQuality: string | null = null; readonly setSubscribed = vi.fn((subscribed: boolean) => { this.subscribed = subscribed; }); - readonly setVideoQuality = vi.fn((quality: string) => { - if (this.subscribed) { - this.requestedVideoQuality = quality; - } - }); constructor( readonly trackSid: string, @@ -285,7 +244,6 @@ const livekit = vi.hoisted(() => { FakeRemoteParticipant, FakeRemotePublication, FakeRoom, - FakeVideoPreset, state, }; }); @@ -310,17 +268,11 @@ const Track = { }, } as const; -const VideoQuality = { - HIGH: "high", -} as const; - vi.mock("livekit-client", () => ({ AudioPresets: livekit.AudioPresets, Room: livekit.FakeRoom, RoomEvent, Track, - VideoPreset: livekit.FakeVideoPreset, - VideoQuality, })); class FakeMediaStream { @@ -407,27 +359,11 @@ function senderReport( framesEncoded: number, ): RTCStatsReport { return statsReport([ - { - id: "video-out-low", - type: "outbound-rtp", - timestamp, - kind: "video", - rid: "q", - mediaSourceId: "video-source", - bytesSent: Math.floor(bytesSent / 4), - framesEncoded: Math.floor(framesEncoded / 4), - framesPerSecond: 15, - frameWidth: 960, - frameHeight: 540, - totalEncodeTime: framesEncoded * 0.002, - qualityLimitationReason: "none", - }, { id: "video-out", type: "outbound-rtp", timestamp, kind: "video", - rid: "h", transportId: "transport", codecId: "codec", mediaSourceId: "video-source", @@ -558,7 +494,7 @@ describe("SfuPublisher", () => { captureHeight: 1080, captureFramesPerSecond: 60, trackIdentifier: previousVideo.id, - rtpRid: "h", + rtpRid: null, mediaSourceFramesPerSecond: 59, framesPerSecond: 57, resolution: "1920x1080", @@ -699,7 +635,7 @@ describe("SfuPublisher", () => { expect(livekit.state.rooms[0]?.disconnect).toHaveBeenCalledWith(false); }); - it("publishes exactly the ordered q and h video encodings", async () => { + it("publishes exactly one VP8 video encoding", async () => { const publisher = new SfuPublisher(); const video = track("video", "video-1"); const audio = track("audio", "audio-1"); @@ -715,21 +651,11 @@ describe("SfuPublisher", () => { source: Track.Source.ScreenShare, backupCodec: false, videoCodec: "vp8", - simulcast: true, + simulcast: false, screenShareEncoding: { maxBitrate: 8_000_000, maxFramerate: 60, }, - screenShareSimulcastLayers: [ - expect.objectContaining({ - width: 960, - height: 540, - encoding: { - maxBitrate: 2_000_000, - maxFramerate: 60, - }, - }), - ], degradationPreference: "maintain-resolution", }); expect( @@ -743,22 +669,13 @@ describe("SfuPublisher", () => { }); expect(sender.setParameters).toHaveBeenCalledOnce(); expect(sender.parameters.encodings).toEqual([ - expect.objectContaining({ - rid: "q", - maxBitrate: 2_000_000, - maxFramerate: 60, - scaleResolutionDownBy: 2, - }), - expect.objectContaining({ - rid: "h", + { maxBitrate: 8_000_000, maxFramerate: 60, scaleResolutionDownBy: 1, - }), + }, ]); - expect( - sender.parameters.encodings.every((encoding) => encoding.active !== false), - ).toBe(true); + expect(sender.parameters.encodings[0]).not.toHaveProperty("rid"); expect(publisher.getSenderParameters()).toEqual({ requested: { maxBitrate: 8_000_000, @@ -858,13 +775,6 @@ describe("SfuPublisher", () => { degradationPreference: "balanced", encodings: [ expect.objectContaining({ - rid: "q", - maxBitrate: 750_000, - maxFramerate: 30, - scaleResolutionDownBy: 2, - }), - expect.objectContaining({ - rid: "h", maxBitrate: 3_000_000, maxFramerate: 30, scaleResolutionDownBy: 1, @@ -1184,13 +1094,6 @@ describe("SfuPublisher", () => { degradationPreference: "maintain-framerate", encodings: [ { - rid: "q", - maxBitrate: 750_000, - maxFramerate: 30, - scaleResolutionDownBy: 2, - }, - { - rid: "h", maxBitrate: 3_000_000, maxFramerate: 30, scaleResolutionDownBy: 1, @@ -1328,41 +1231,6 @@ describe("SfuPublisher", () => { expect(publisher.getQualityWarning()).toContain("码率上限"); }); - it("reports a LOW rewrite while retaining HIGH sender readback", 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, index) => - index === 0 - ? { ...encoding, maxBitrate: 200_000 } - : { ...encoding }, - ), - }; - }); - - await expect( - publisher.updateProfile({ - resolution: "1080p", - maxFramerate: 30, - maxBitrate: 5_000_000, - degradationPreference: "balanced", - }), - ).resolves.toBe(true); - - expect(publisher.getSenderParameters()).toMatchObject({ - requested: { maxBitrate: 5_000_000 }, - applied: { maxBitrate: 5_000_000 }, - mismatches: [], - }); - expect(publisher.getQualityWarning()).toContain("低档表示"); - expect(publisher.getQualityWarning()).toContain("码率上限"); - }); - it("retains a visible warning when SFU sender parameters are rejected", async () => { const publisher = new SfuPublisher(); await publisher.connect(connection); @@ -1396,12 +1264,6 @@ describe("SfuPublisher", () => { degradationPreference: "maintain-resolution", encodings: [ expect.objectContaining({ - rid: "q", - maxBitrate: 2_000_000, - maxFramerate: 60, - }), - expect.objectContaining({ - rid: "h", maxBitrate: 8_000_000, maxFramerate: 60, }), @@ -1411,7 +1273,7 @@ describe("SfuPublisher", () => { expect(publisher.getQualityWarning()).not.toContain("unsupported"); }); - it("retains a LOW rewrite warning after rolling back sender parameters", async () => { + it("retains a sender rewrite warning after rolling back parameters", async () => { const publisher = new SfuPublisher(); await publisher.connect(connection); await publisher.activate(stream(track("video", "video-1")), qualityProfile); @@ -1422,11 +1284,10 @@ describe("SfuPublisher", () => { .mockImplementationOnce(async (parameters) => { sender.parameters = { ...parameters, - encodings: parameters.encodings.map((encoding, index) => - index === 0 - ? { ...encoding, maxBitrate: 1_500_000 } - : { ...encoding }, - ), + encodings: parameters.encodings.map((encoding) => ({ + ...encoding, + maxBitrate: 1_500_000, + })), }; }); @@ -1441,12 +1302,11 @@ describe("SfuPublisher", () => { expect(publisher.getSenderParameters()).toMatchObject({ requested: { maxBitrate: 8_000_000 }, - applied: { maxBitrate: 8_000_000 }, - mismatches: [], + applied: { maxBitrate: 1_500_000 }, + mismatches: ["maxBitrate"], }); expect(publisher.getQualityWarning()).toContain("应用 SFU 发送参数失败"); expect(publisher.getQualityWarning()).not.toContain("unsupported"); - expect(publisher.getQualityWarning()).toContain("低档表示"); expect(publisher.getQualityWarning()).toContain("码率上限"); }); @@ -1648,7 +1508,6 @@ describe("SfuSubscriber", () => { expect(subscriber.activate()).toBe(true); expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true); - expect(hostVideo.setVideoQuality).toHaveBeenCalledWith(VideoQuality.HIGH); }); it("reconciles a Host participant that appears after activation", async () => { @@ -1666,10 +1525,9 @@ describe("SfuSubscriber", () => { room.emit(RoomEvent.ParticipantConnected, host); expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true); - expect(hostVideo.setVideoQuality).toHaveBeenCalledWith(VideoQuality.HIGH); }); - it("keeps the assigned screen subscription at a HIGH ceiling", async () => { + it("subscribes only to assigned Host screen tracks", async () => { const gate = deferred(); livekit.state.connectGate = gate.promise; const streams: Array = []; @@ -1711,19 +1569,9 @@ describe("SfuSubscriber", () => { expect(hostVideo.setSubscribed).not.toHaveBeenCalled(); expect(streams).toEqual([]); - hostVideo.setVideoQuality(VideoQuality.HIGH); - expect(hostVideo.requestedVideoQuality).toBeNull(); - hostVideo.setVideoQuality.mockClear(); - expect(subscriber.activate()).toBe(true); expect(hostVideo.setSubscribed).toHaveBeenCalledWith(true); expect(hostAudio.setSubscribed).toHaveBeenCalledWith(true); - expect(hostVideo.setVideoQuality).toHaveBeenCalledWith(VideoQuality.HIGH); - expect(hostVideo.requestedVideoQuality).toBe(VideoQuality.HIGH); - expect(hostVideo.setSubscribed.mock.invocationCallOrder[0]).toBeLessThan( - hostVideo.setVideoQuality.mock.invocationCallOrder[0]!, - ); - expect(hostAudio.setVideoQuality).not.toHaveBeenCalled(); expect(hostCamera.setSubscribed).not.toHaveBeenCalled(); expect(viewerScreen.setSubscribed).not.toHaveBeenCalled();