fix(sfu): publish a single high representation (#248)

This commit is contained in:
AAA authored and GitHub committed 2026-08-25 05:34:19 +08:00
1 parent 3cc858477b
commit d39f4359cd
11 files changed
+46 -387

No files matched your search

+2 -2
View File
@@ -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.
+6 -7
View File
@@ -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)
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -69
View File
@@ -93,19 +93,12 @@ export interface VideoSenderParameterReadback {
mismatches: Array<keyof VideoSenderParameterValues>;
}
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<TwoLayerVideoSenderParameterReadback> {
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: "帧率上限",
+8 -35
View File
@@ -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<vo
async function configurePublishedVideo(
published: PublishedTrack,
profile: QualityProfile,
sdk: LiveKit,
ownsPublication: () => boolean,
): Promise<PublishedVideoConfiguration | null> {
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>): 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,
};
}
-5
View File
@@ -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(
-89
View File
@@ -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();
+21 -173
View File
@@ -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<MediaStream | null> = [];
@@ -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();