feat: add built-in two-layer SFU quality candidate
This commit is contained in:
1 parent
227fdbc2c8
commit
05606fbd84
8 files changed
+462
-46
No files matched your search
@@ -146,7 +146,7 @@ evidence windows remain limited to topology eligibility and diagnostics:
|
||||
required product semantic is absent. In particular, the normative rule that
|
||||
`active=false` stops sending an encoding is not evidence that its physical
|
||||
encoder, CPU work, or GPU allocation is released.
|
||||
2. **Pinned LiveKit two-layer simulcast: next bounded runtime candidate.**
|
||||
2. **Pinned LiveKit two-layer simulcast: implemented default-off runtime candidate.**
|
||||
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
|
||||
@@ -157,7 +157,7 @@ evidence windows remain limited to topology eligibility and diagnostics:
|
||||
dynamic `LOW` stop while `HIGH` is subscribed, but dynamic stop is now an
|
||||
optimization rather than a hard requirement.
|
||||
|
||||
The next gate therefore publishes exactly `HIGH+LOW` with standard
|
||||
The candidate therefore publishes exactly `HIGH+LOW` with standard
|
||||
simulcast/send encodings, leaves each LiveKit root's ceiling at `HIGH`, and
|
||||
first tests built-in per-subscriber SFU bandwidth adaptation on
|
||||
zero-descendant leaf viewers. Start with deterministic two-layer publication
|
||||
@@ -173,10 +173,11 @@ evidence windows remain limited to topology eligibility and diagnostics:
|
||||
before sender activation/deactivation. The gate must prove that a healthy
|
||||
P2P/`HIGH` path is unchanged, no third layer appears, the expected layer is
|
||||
actually received, and hardware encoder, game FPS/p1 low, CPU/GPU, interval
|
||||
encode cost, host upload, and `HIGH+LOW` bytes fit budget. Screener currently
|
||||
sets `simulcast: false`, leaves Dynacast at its default `false`, and does not
|
||||
call `setVideoQuality()`, so no runtime claim follows and no browser is run
|
||||
in this static SVC review. If the always-on cost fails, test manual standard
|
||||
encode cost, host upload, and `HIGH+LOW` bytes fit budget. The default-off
|
||||
exact-room candidate now configures exactly two encodings, leaves Dynacast at
|
||||
its default `false`, and sets each subscriber's ceiling to `HIGH`. It has not
|
||||
been browser-tested or enabled by default, so no runtime performance claim
|
||||
follows. If the always-on cost fails, test manual standard
|
||||
sender activation/deactivation next; custom/native dual encoding follows
|
||||
only if built-in and manual standard primitives fail.
|
||||
3. **Current Web/LiveKit SVC: rejected
|
||||
|
||||
@@ -48,8 +48,8 @@ Last updated: 2026-08-19
|
||||
- Chrome 151/LiveKit localhost A/B cut failure-to-active/render from 1.481/2.257 seconds to 0.200/0.320; 31 new frames and 25 ms sampling kept host edges at two. It is headless synthetic 720p30 and includes SDK/network prewarm, not public-network evidence.
|
||||
- Native drafts #16/#18/#22/#23/#25/#28 remain experiments: one WebCodecs object is not hardware proof; stock GCC+RTX is no-go, and no-RTX remains outside product code.
|
||||
- Chrome 151 synthetic topology/quality-control runs kept fanout 2/1 and all viewers decoding; one relay close recovered in 5.32 seconds. This is control evidence only.
|
||||
- ADR-0007's dual publication/topology classifier is absent. Next measure LiveKit `HIGH+LOW` BWE on SFU leaves, then root suspect/evacuation; app evidence never duplicates normal layer selection. Current publishing is non-simulcast/Dynacast-off.
|
||||
- Local Host A+B aligns one capture/outbound/transport generation; authenticated Viewer C adds a sanitized, read-only P2P window and fails closed for stale, ambiguous, or SFU-fed evidence. It retains no raw fmtp/SDP/stats and performs no media action. Dual publication and topology classification remain absent.
|
||||
- The default-off exact-room SFU candidate now publishes exactly `HIGH+LOW` with Dynacast off and a per-subscriber `HIGH` ceiling. It is unverified and not enabled by default; next measure built-in BWE on zero-descendant leaves, then root suspect/evacuation. The topology classifier and explicit fallback remain absent, and root-with-children behavior is a default-on gate.
|
||||
- Local Host A+B aligns one capture/outbound/transport generation; authenticated Viewer C adds a sanitized, read-only P2P window and fails closed for stale, ambiguous, or SFU-fed evidence. It retains no raw fmtp/SDP/stats and performs no media action. Topology classification remains absent.
|
||||
|
||||
## Provisional Quality Targets
|
||||
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN.
|
||||
- Current SQLite still stores only room ID and Host-token digest. Amended ADR-0002 accepts a later atomic `HOST_ADMISSION_PASSWORD`/`screener-v2` migration with default room-scoped private Viewer grants, explicit public-watch, rotate/revoke, and one nullable digest column; no runtime is implemented.
|
||||
- PR #44 merged the default-off exact-room candidate code: ordinary ICE is process-wide STUN-only, LiveKit SFU/UDP feeds at most two roots, browser relays stay at one child, the host stays at two, and exhaustion fails boundedly. Old production/coturn remains rollback; HTTPS/WSS stays TLS/TCP.
|
||||
- Fallback prewarm is token-free and dormant without configuration. Mobile/iPad viewers are leaves and healthy edges stay sticky.
|
||||
- Manual readback and read-only A+B/C exist; dual publication/topology classification do not. Always-on `LOW` needs exactly-two, hardware, game, and upload gates. Browser audio is request/presence-only.
|
||||
- Manual readback and read-only A+B/C exist. The default-off exact-room SFU candidate now configures exactly `HIGH+LOW`, keeps Dynacast off, and sets a per-subscriber `HIGH` ceiling; topology classification remains absent. Browser audio is request/presence-only.
|
||||
- A later explicit fallback evacuates first. Autonomous BWE enters `suspect`; confirmation evacuates children, and no confirmed `FALLBACK` parent remains. Root impact is a default-on gate; self-report alone cannot trigger it.
|
||||
|
||||
## Verified Evidence
|
||||
@@ -37,7 +37,7 @@ unconfigured, so production remains one host connection per viewer and P2P/TURN.
|
||||
- Production `769de201f7cc` reportedly sustains bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not, and persistence across churn was also reported. This prioritizes, but does not prove, host sender/PC/GCC/capture generation.
|
||||
- Browser relays re-encode. ADR-0006 is no-go-unclassified: downstream checkpoints and two-edge/FIFO/TURN gates are absent. Spikes prove one WebCodecs object, not hardware; GCC+RTX is no-go and no-RTX weakens stats.
|
||||
- Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges.
|
||||
- ADR-0007/SFU C are incomplete. Next is LiveKit exactly-two BWE on SFU leaves, then root suspect/evacuation. Web P2P/current SVC shortcuts are no-go; publishing is non-simulcast/Dynacast-off.
|
||||
- ADR-0007/SFU C remain unverified: the exactly-two candidate has no browser evidence or default enablement. Next is built-in BWE on zero-descendant SFU leaves, then root suspect/evacuation; root-with-children behavior is a default-on gate. Web P2P/current SVC shortcuts remain no-go.
|
||||
- The corrected standby smoke is localhost/headless/video-only; public DNS/TLS reuse, transport, audio, shaping, load, mobile, endurance, and sub-25 ms overlap remain open.
|
||||
- Scoped Viewer access is designed but unimplemented. Tests owe role bounds, fragment clearing/no-leak, expiry/cross-room/rotate/revoke, v1 tab termination, and SQLite locked migration/restore. Human passwords/accounts stay out.
|
||||
|
||||
|
||||
@@ -66,6 +66,17 @@ export interface VideoSenderParameterReadback {
|
||||
mismatches: Array<keyof VideoSenderParameterValues>;
|
||||
}
|
||||
|
||||
export interface TwoLayerVideoSenderParameterReadback {
|
||||
low: VideoSenderParameterReadback;
|
||||
high: VideoSenderParameterReadback;
|
||||
}
|
||||
|
||||
export const SCREEN_SHARE_LOW_SCALE = 2;
|
||||
|
||||
export function screenShareLowBitrate(profile: QualityProfile): number {
|
||||
return Math.max(150_000, Math.floor(profile.maxBitrate / 4));
|
||||
}
|
||||
|
||||
export function qualitySettingsEqual(
|
||||
left: QualitySettings,
|
||||
right: QualitySettings,
|
||||
@@ -172,8 +183,9 @@ function requestedScaleResolutionDownBy(
|
||||
function readVideoSenderParameters(
|
||||
parameters: RTCRtpSendParameters,
|
||||
includeAppliedScalabilityMode: boolean,
|
||||
encodingIndex = 0,
|
||||
): VideoSenderParameterValues {
|
||||
const encoding = parameters.encodings[0];
|
||||
const encoding = parameters.encodings[encodingIndex];
|
||||
const scalabilityMode = (
|
||||
encoding as
|
||||
| (RTCRtpEncodingParameters & { scalabilityMode?: unknown })
|
||||
@@ -194,6 +206,31 @@ function readVideoSenderParameters(
|
||||
};
|
||||
}
|
||||
|
||||
function senderParameterReadback(
|
||||
requested: VideoSenderParameterValues,
|
||||
applied: VideoSenderParameterValues,
|
||||
): VideoSenderParameterReadback {
|
||||
const mismatches = (
|
||||
Object.keys(requested) as Array<keyof VideoSenderParameterValues>
|
||||
).filter((key) => !sameParameter(key, requested[key], applied[key]));
|
||||
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 !== "f"
|
||||
) {
|
||||
throw new Error("SFU simulcast requires ordered q and f video encodings");
|
||||
}
|
||||
return { low: 0, high: 1 };
|
||||
}
|
||||
|
||||
function sameParameter(
|
||||
key: keyof VideoSenderParameterValues,
|
||||
requested: number | string | null,
|
||||
@@ -227,11 +264,48 @@ export async function configureVideoSender(
|
||||
const requested = readVideoSenderParameters(parameters, false);
|
||||
await sender.setParameters(parameters);
|
||||
const applied = readVideoSenderParameters(sender.getParameters(), true);
|
||||
const mismatches = (
|
||||
Object.keys(requested) as Array<keyof VideoSenderParameterValues>
|
||||
).filter((key) => !sameParameter(key, requested[key], applied[key]));
|
||||
return senderParameterReadback(requested, applied);
|
||||
}
|
||||
|
||||
return { requested, applied, mismatches };
|
||||
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 = {
|
||||
|
||||
+82
-24
@@ -6,7 +6,10 @@ import type {
|
||||
} from "livekit-client";
|
||||
|
||||
import {
|
||||
configureVideoSender,
|
||||
configureTwoLayerVideoSender,
|
||||
QUALITY_RESOLUTIONS,
|
||||
SCREEN_SHARE_LOW_SCALE,
|
||||
screenShareLowBitrate,
|
||||
senderParameterWarning,
|
||||
type QualityProfile,
|
||||
type VideoSenderParameterReadback,
|
||||
@@ -26,6 +29,11 @@ interface PublishedTrack {
|
||||
rawTrack: MediaStreamTrack;
|
||||
}
|
||||
|
||||
interface PublishedVideoConfiguration {
|
||||
readback: VideoSenderParameterReadback;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
type LiveKit = typeof import("livekit-client");
|
||||
type PublisherState =
|
||||
| "idle"
|
||||
@@ -118,12 +126,16 @@ export class SfuPublisher {
|
||||
room,
|
||||
videoTrack,
|
||||
sdk.Track.Source.ScreenShare,
|
||||
videoPublishOptions(profile),
|
||||
videoPublishOptions(sdk, profile),
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const senderParameters = await configurePublishedVideo(video, profile);
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
video,
|
||||
profile,
|
||||
sdk,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
@@ -144,7 +156,7 @@ export class SfuPublisher {
|
||||
this.video = video;
|
||||
this.audio = audio;
|
||||
this.profile = profile;
|
||||
this.retainSenderParameters(senderParameters);
|
||||
this.retainSenderParameters(videoConfiguration);
|
||||
this.state = "active";
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -251,9 +263,10 @@ export class SfuPublisher {
|
||||
this.audio = nextAudio;
|
||||
}
|
||||
|
||||
const senderParameters = await configurePublishedVideo(
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
previousVideo,
|
||||
profile,
|
||||
sdk,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
@@ -262,7 +275,7 @@ export class SfuPublisher {
|
||||
if (previousAudio && nextAudioTrack) {
|
||||
previousAudio.rawTrack = nextAudioTrack;
|
||||
}
|
||||
this.retainSenderParameters(senderParameters);
|
||||
this.retainSenderParameters(videoConfiguration);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.owns(room, generation)) {
|
||||
@@ -295,15 +308,19 @@ export class SfuPublisher {
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
const senderParameters = await configurePublishedVideo(
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
previousVideo,
|
||||
profile,
|
||||
sdk,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.senderParameters = senderParameters;
|
||||
this.qualityWarning = failureWarning;
|
||||
this.senderParameters = videoConfiguration.readback;
|
||||
this.qualityWarning = mergeQualityWarnings(
|
||||
failureWarning,
|
||||
videoConfiguration.warning,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
@@ -325,17 +342,22 @@ export class SfuPublisher {
|
||||
}
|
||||
const video = this.video;
|
||||
const previousProfile = this.profile;
|
||||
if (!video || !previousProfile) {
|
||||
const sdk = this.sdk;
|
||||
if (!video || !previousProfile || !sdk) {
|
||||
throw new Error("SFU publisher has no active video publication");
|
||||
}
|
||||
|
||||
try {
|
||||
const readback = await configurePublishedVideo(video, profile);
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
video,
|
||||
profile,
|
||||
sdk,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.profile = profile;
|
||||
this.retainSenderParameters(readback);
|
||||
this.retainSenderParameters(videoConfiguration);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.owns(room, generation)) {
|
||||
@@ -346,12 +368,19 @@ export class SfuPublisher {
|
||||
? `应用 SFU 发送参数失败:${error.message}`
|
||||
: "应用 SFU 发送参数失败";
|
||||
try {
|
||||
const readback = await configurePublishedVideo(video, previousProfile);
|
||||
const videoConfiguration = await configurePublishedVideo(
|
||||
video,
|
||||
previousProfile,
|
||||
sdk,
|
||||
);
|
||||
if (!this.owns(room, generation)) {
|
||||
return false;
|
||||
}
|
||||
this.senderParameters = readback;
|
||||
this.qualityWarning = failureWarning;
|
||||
this.senderParameters = videoConfiguration.readback;
|
||||
this.qualityWarning = mergeQualityWarnings(
|
||||
failureWarning,
|
||||
videoConfiguration.warning,
|
||||
);
|
||||
return false;
|
||||
} catch (rollbackError) {
|
||||
if (this.owns(room, generation)) {
|
||||
@@ -449,10 +478,10 @@ export class SfuPublisher {
|
||||
}
|
||||
|
||||
private retainSenderParameters(
|
||||
readback: VideoSenderParameterReadback,
|
||||
configuration: PublishedVideoConfiguration,
|
||||
): void {
|
||||
this.senderParameters = readback;
|
||||
this.qualityWarning = senderParameterWarning(readback);
|
||||
this.senderParameters = configuration.readback;
|
||||
this.qualityWarning = configuration.warning;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,27 +523,56 @@ async function unpublishTrack(room: Room, published: PublishedTrack): Promise<vo
|
||||
async function configurePublishedVideo(
|
||||
published: PublishedTrack,
|
||||
profile: QualityProfile,
|
||||
): Promise<VideoSenderParameterReadback> {
|
||||
sdk: LiveKit,
|
||||
): Promise<PublishedVideoConfiguration> {
|
||||
const videoTrack = published.publication.videoTrack;
|
||||
const sender = videoTrack?.sender;
|
||||
if (!sender) {
|
||||
throw new Error("SFU video publication has no RTP sender");
|
||||
}
|
||||
const readback = await configureVideoSender(sender, profile);
|
||||
const readbacks = await configureTwoLayerVideoSender(sender, profile);
|
||||
videoTrack.publishOptions = {
|
||||
...videoTrack.publishOptions,
|
||||
...videoPublishOptions(profile),
|
||||
...videoPublishOptions(sdk, profile),
|
||||
};
|
||||
const highWarning = senderParameterWarning(readbacks.high);
|
||||
const lowWarning = senderParameterWarning(readbacks.low);
|
||||
return {
|
||||
readback: readbacks.high,
|
||||
warning: mergeQualityWarnings(
|
||||
highWarning,
|
||||
lowWarning ? `低档表示:${lowWarning}` : null,
|
||||
),
|
||||
};
|
||||
return readback;
|
||||
}
|
||||
|
||||
function videoPublishOptions(profile: QualityProfile): TrackPublishOptions {
|
||||
function mergeQualityWarnings(...warnings: Array<string | null>): string | null {
|
||||
const present = warnings.filter(
|
||||
(warning): warning is string => warning !== null,
|
||||
);
|
||||
return present.length > 0 ? present.join(";") : null;
|
||||
}
|
||||
|
||||
function videoPublishOptions(
|
||||
sdk: LiveKit,
|
||||
profile: QualityProfile,
|
||||
): TrackPublishOptions {
|
||||
const highResolution = QUALITY_RESOLUTIONS[profile.resolution];
|
||||
return {
|
||||
simulcast: false,
|
||||
backupCodec: false,
|
||||
simulcast: true,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export class SfuSubscriber {
|
||||
const host = room.remoteParticipants.get(HOST_IDENTITY);
|
||||
if (host) {
|
||||
for (const publication of host.trackPublications.values()) {
|
||||
this.subscribeIfAllowed(publication, host, this.sdk.Track);
|
||||
this.subscribeIfAllowed(publication, host, this.sdk);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -155,7 +155,7 @@ export class SfuSubscriber {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.subscribeIfAllowed(publication, participant, sdk.Track);
|
||||
this.subscribeIfAllowed(publication, participant, sdk);
|
||||
} catch {
|
||||
void this.failClosed(room, generation);
|
||||
}
|
||||
@@ -228,16 +228,21 @@ export class SfuSubscriber {
|
||||
private subscribeIfAllowed(
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
trackType: typeof Track,
|
||||
sdk: LiveKit,
|
||||
): void {
|
||||
if (
|
||||
participant.identity !== HOST_IDENTITY ||
|
||||
!isScreenSource(publication.source, trackType)
|
||||
!isScreenSource(publication.source, sdk.Track)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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 rollbackSubscriptions(room: Room): void {
|
||||
|
||||
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyCaptureProfile,
|
||||
captureDisplay,
|
||||
configureTwoLayerVideoSender,
|
||||
configureVideoSender,
|
||||
QUALITY_PROFILES,
|
||||
screenShareLowBitrate,
|
||||
senderParameterWarning,
|
||||
setVideoPaused,
|
||||
} from "../src/client/media/quality.ts";
|
||||
@@ -213,6 +215,93 @@ 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: "f" }],
|
||||
} 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: "maintain-resolution",
|
||||
encodings: [
|
||||
{
|
||||
rid: "q",
|
||||
maxBitrate: 2_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 8 / 3,
|
||||
},
|
||||
{
|
||||
rid: "f",
|
||||
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: "f" }, { rid: "q" }] },
|
||||
message: "ordered q and f 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: "f" }],
|
||||
} 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 only the video track and can resume it", () => {
|
||||
const { stream, videoTrack } = createVideoStream();
|
||||
|
||||
|
||||
+192
-3
@@ -6,6 +6,30 @@ import { SfuSubscriber } from "../src/client/sfu/subscriber.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 {
|
||||
parameters: RTCRtpSendParameters = {
|
||||
codecs: [],
|
||||
@@ -57,6 +81,36 @@ const livekit = vi.hoisted(() => {
|
||||
options: Record<string, unknown>,
|
||||
) => {
|
||||
const localTrack = new FakeLocalTrack(rawTrack);
|
||||
const simulcastLayers = options.screenShareSimulcastLayers;
|
||||
const highEncoding = options.screenShareEncoding;
|
||||
if (
|
||||
rawTrack.kind === "video" &&
|
||||
options.simulcast === true &&
|
||||
Array.isArray(simulcastLayers) &&
|
||||
simulcastLayers.length === 1 &&
|
||||
simulcastLayers[0] instanceof FakeVideoPreset &&
|
||||
typeof highEncoding === "object" &&
|
||||
highEncoding !== null
|
||||
) {
|
||||
const high = highEncoding as {
|
||||
maxBitrate?: number;
|
||||
maxFramerate?: number;
|
||||
};
|
||||
localTrack.sender.parameters.encodings = [
|
||||
{
|
||||
rid: "q",
|
||||
maxBitrate: simulcastLayers[0].encoding.maxBitrate,
|
||||
maxFramerate: simulcastLayers[0].encoding.maxFramerate,
|
||||
scaleResolutionDownBy: 2,
|
||||
},
|
||||
{
|
||||
rid: "f",
|
||||
maxBitrate: high.maxBitrate,
|
||||
maxFramerate: high.maxFramerate,
|
||||
scaleResolutionDownBy: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
const publication = {
|
||||
track: localTrack,
|
||||
videoTrack: rawTrack.kind === "video" ? localTrack : undefined,
|
||||
@@ -74,7 +128,16 @@ const livekit = vi.hoisted(() => {
|
||||
}
|
||||
|
||||
class FakeRemotePublication {
|
||||
readonly setSubscribed = vi.fn((_subscribed: boolean) => undefined);
|
||||
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,
|
||||
@@ -108,7 +171,7 @@ const livekit = vi.hoisted(() => {
|
||||
);
|
||||
readonly disconnect = vi.fn(async (_stopTracks?: boolean) => undefined);
|
||||
|
||||
constructor() {
|
||||
constructor(readonly options: Record<string, unknown> = {}) {
|
||||
state.rooms.push(this);
|
||||
}
|
||||
|
||||
@@ -140,6 +203,7 @@ const livekit = vi.hoisted(() => {
|
||||
FakeRemoteParticipant,
|
||||
FakeRemotePublication,
|
||||
FakeRoom,
|
||||
FakeVideoPreset,
|
||||
state,
|
||||
};
|
||||
});
|
||||
@@ -161,10 +225,16 @@ const Track = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VideoQuality = {
|
||||
HIGH: "high",
|
||||
} as const;
|
||||
|
||||
vi.mock("livekit-client", () => ({
|
||||
Room: livekit.FakeRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoPreset: livekit.FakeVideoPreset,
|
||||
VideoQuality,
|
||||
}));
|
||||
|
||||
class FakeMediaStream {
|
||||
@@ -232,6 +302,7 @@ describe("SfuPublisher", () => {
|
||||
await expect(publisher.connect(connection)).resolves.toBe(true);
|
||||
|
||||
const room = livekit.state.rooms[0];
|
||||
expect(room.options).toEqual({});
|
||||
expect(room.connect).toHaveBeenCalledWith(connection.url, connection.token, {
|
||||
autoSubscribe: false,
|
||||
});
|
||||
@@ -257,11 +328,22 @@ describe("SfuPublisher", () => {
|
||||
const sender = room.localParticipant.publications[0].track.sender;
|
||||
expect(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(1, video, {
|
||||
source: Track.Source.ScreenShare,
|
||||
simulcast: false,
|
||||
backupCodec: false,
|
||||
simulcast: true,
|
||||
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(room.localParticipant.publishTrack).toHaveBeenNthCalledWith(2, audio, {
|
||||
@@ -269,6 +351,20 @@ describe("SfuPublisher", () => {
|
||||
dtx: false,
|
||||
});
|
||||
expect(sender.setParameters).toHaveBeenCalledOnce();
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
expect.objectContaining({
|
||||
rid: "q",
|
||||
maxBitrate: 2_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
rid: "f",
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 60,
|
||||
scaleResolutionDownBy: 1,
|
||||
}),
|
||||
]);
|
||||
expect(publisher.getSenderParameters()).toEqual({
|
||||
requested: {
|
||||
maxBitrate: 8_000_000,
|
||||
@@ -328,8 +424,16 @@ describe("SfuPublisher", () => {
|
||||
degradationPreference: "balanced",
|
||||
encodings: [
|
||||
expect.objectContaining({
|
||||
rid: "q",
|
||||
maxBitrate: 750_000,
|
||||
maxFramerate: 30,
|
||||
scaleResolutionDownBy: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
rid: "f",
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 30,
|
||||
scaleResolutionDownBy: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -366,6 +470,41 @@ 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);
|
||||
@@ -388,6 +527,46 @@ describe("SfuPublisher", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retains a LOW rewrite warning after rolling back sender parameters", async () => {
|
||||
const publisher = new SfuPublisher();
|
||||
await publisher.connect(connection);
|
||||
await publisher.activate(stream(track("video", "video-1")), qualityProfile);
|
||||
const sender = livekit.state.rooms[0].localParticipant.publications[0].track
|
||||
.sender;
|
||||
sender.setParameters
|
||||
.mockRejectedValueOnce(new Error("unsupported"))
|
||||
.mockImplementationOnce(async (parameters) => {
|
||||
sender.parameters = {
|
||||
...parameters,
|
||||
encodings: parameters.encodings.map((encoding, index) =>
|
||||
index === 0
|
||||
? { ...encoding, maxBitrate: 1_500_000 }
|
||||
: { ...encoding },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
publisher.updateProfile({
|
||||
resolution: "720p",
|
||||
maxFramerate: 30,
|
||||
maxBitrate: 3_000_000,
|
||||
degradationPreference: "balanced",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(publisher.getSenderParameters()).toMatchObject({
|
||||
requested: { maxBitrate: 8_000_000 },
|
||||
applied: { maxBitrate: 8_000_000 },
|
||||
mismatches: [],
|
||||
});
|
||||
expect(publisher.getQualityWarning()).toContain(
|
||||
"应用 SFU 发送参数失败:unsupported",
|
||||
);
|
||||
expect(publisher.getQualityWarning()).toContain("低档表示");
|
||||
expect(publisher.getQualityWarning()).toContain("码率上限");
|
||||
});
|
||||
|
||||
it("restores the previous video after a partially applied replacement fails", async () => {
|
||||
const previousVideo = track("video", "video-1");
|
||||
const nextVideo = track("video", "video-2");
|
||||
@@ -526,9 +705,19 @@ 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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user