feat: add built-in two-layer SFU quality candidate

This commit is contained in:
AAA committed 2026-08-19 23:39:17 +08:00
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
+2 -2
View File
@@ -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
View File
@@ -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.
+79 -5
View File
@@ -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
View File
@@ -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,
};
}
+9 -4
View File
@@ -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 {
+89
View File
@@ -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
View File
@@ -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();