Add configurable 64/128/256 kbps screen-audio presets across P2P, relay, and SFU paths.
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
applyCaptureProfile,
|
|
captureDisplay,
|
|
configureScreenAudioSender,
|
|
configureTwoLayerVideoSender,
|
|
configureVideoSender,
|
|
matchingQualityProfileId,
|
|
QUALITY_PROFILES,
|
|
qualitySettingsEqual,
|
|
SCREEN_AUDIO_BITRATES,
|
|
screenShareLowBitrate,
|
|
senderParameterWarning,
|
|
setMediaPaused,
|
|
} from "../src/client/media/quality.ts";
|
|
|
|
function createVideoStream() {
|
|
const videoTrack = {
|
|
contentHint: "",
|
|
enabled: true,
|
|
applyConstraints: vi.fn(async () => undefined),
|
|
getSettings: () => ({ width: 1920, height: 1080, frameRate: 60 }),
|
|
} as unknown as MediaStreamTrack;
|
|
const audioTrack = {
|
|
contentHint: "",
|
|
enabled: true,
|
|
} as unknown as MediaStreamTrack;
|
|
const stream = {
|
|
getTracks: () => [videoTrack, audioTrack],
|
|
getVideoTracks: () => [videoTrack],
|
|
getAudioTracks: () => [audioTrack],
|
|
} as unknown as MediaStream;
|
|
return { stream, videoTrack, audioTrack };
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("realtime quality controls", () => {
|
|
it("bounds initial capture to the selected profile", async () => {
|
|
const { stream, videoTrack, audioTrack } = createVideoStream();
|
|
const getDisplayMedia = vi.fn(async () => stream);
|
|
vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } });
|
|
|
|
await expect(captureDisplay(QUALITY_PROFILES["1080p30"])).resolves.toBe(
|
|
stream,
|
|
);
|
|
|
|
expect(getDisplayMedia).toHaveBeenCalledWith({
|
|
video: {
|
|
width: { ideal: 1920, max: 1920 },
|
|
height: { ideal: 1080, max: 1080 },
|
|
frameRate: { ideal: 30, max: 30 },
|
|
},
|
|
audio: {
|
|
echoCancellation: false,
|
|
noiseSuppression: false,
|
|
autoGainControl: false,
|
|
voiceIsolation: false,
|
|
channelCount: { ideal: 2 },
|
|
},
|
|
systemAudio: "include",
|
|
windowAudio: "window",
|
|
});
|
|
expect(videoTrack.contentHint).toBe("motion");
|
|
expect(audioTrack.contentHint).toBe("music");
|
|
});
|
|
|
|
it("changes custom capture ceilings without selecting the source again", async () => {
|
|
const { stream, videoTrack } = createVideoStream();
|
|
|
|
await applyCaptureProfile(stream, {
|
|
resolution: "1440p",
|
|
maxFramerate: 45,
|
|
maxBitrate: 9_500_000,
|
|
degradationPreference: "balanced",
|
|
});
|
|
|
|
expect(videoTrack.applyConstraints).toHaveBeenCalledWith({
|
|
width: { ideal: 2560, max: 2560 },
|
|
height: { ideal: 1440, max: 1440 },
|
|
frameRate: { ideal: 45, max: 45 },
|
|
});
|
|
});
|
|
|
|
it("defaults every recommended profile to balanced degradation", () => {
|
|
expect(
|
|
Object.values(QUALITY_PROFILES).every(
|
|
(profile) =>
|
|
profile.degradationPreference === "balanced",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps codec selection orthogonal to the recommended quality profile", () => {
|
|
const h264 = { ...QUALITY_PROFILES["1080p60"], videoCodec: "h264" } as const;
|
|
expect(matchingQualityProfileId(h264)).toBe("1080p60");
|
|
expect(qualitySettingsEqual(h264, QUALITY_PROFILES["1080p60"])).toBe(false);
|
|
});
|
|
|
|
it("keeps audio selection orthogonal while defaulting old settings to music", () => {
|
|
const saver = {
|
|
...QUALITY_PROFILES["1080p60"],
|
|
screenAudioQuality: "saver",
|
|
} as const;
|
|
const {
|
|
screenAudioQuality: _screenAudioQuality,
|
|
...legacySettings
|
|
} = QUALITY_PROFILES["1080p60"];
|
|
|
|
expect(matchingQualityProfileId(saver)).toBe("1080p60");
|
|
expect(qualitySettingsEqual(saver, QUALITY_PROFILES["1080p60"])).toBe(false);
|
|
expect(
|
|
qualitySettingsEqual(legacySettings, QUALITY_PROFILES["1080p60"]),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("reads back every requested sender control after setParameters", async () => {
|
|
let applied = { encodings: [] } as unknown as RTCRtpSendParameters;
|
|
const setParameters = vi.fn(async (parameters: RTCRtpSendParameters) => {
|
|
applied = parameters;
|
|
});
|
|
const sender = {
|
|
track: {
|
|
getSettings: () => ({ width: 2560, height: 1440 }),
|
|
},
|
|
getParameters: () => applied,
|
|
setParameters,
|
|
} as unknown as RTCRtpSender;
|
|
|
|
const readback = await configureVideoSender(
|
|
sender,
|
|
QUALITY_PROFILES["1080p60"],
|
|
);
|
|
|
|
expect(readback).toEqual({
|
|
requested: {
|
|
maxBitrate: 8_000_000,
|
|
maxFramerate: 60,
|
|
scaleResolutionDownBy: 4 / 3,
|
|
degradationPreference: "balanced",
|
|
scalabilityMode: null,
|
|
},
|
|
applied: {
|
|
maxBitrate: 8_000_000,
|
|
maxFramerate: 60,
|
|
scaleResolutionDownBy: 4 / 3,
|
|
degradationPreference: "balanced",
|
|
scalabilityMode: null,
|
|
},
|
|
mismatches: [],
|
|
});
|
|
expect(senderParameterWarning(readback)).toBeNull();
|
|
expect(setParameters).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([
|
|
["saver", 64_000],
|
|
["music", 128_000],
|
|
["very-high", 256_000],
|
|
] as const)("applies and reads back the %s audio ceiling", async (quality, bitrate) => {
|
|
let applied = { encodings: [] } as unknown as RTCRtpSendParameters;
|
|
const sender = {
|
|
getParameters: () => applied,
|
|
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
|
|
applied = parameters;
|
|
}),
|
|
} as unknown as RTCRtpSender;
|
|
|
|
await expect(configureScreenAudioSender(sender, quality)).resolves.toBe(
|
|
bitrate,
|
|
);
|
|
expect(applied.encodings).toEqual([{ maxBitrate: bitrate }]);
|
|
expect(SCREEN_AUDIO_BITRATES[quality]).toBe(bitrate);
|
|
});
|
|
|
|
it("uses the music ceiling for a legacy setting without an audio preset", async () => {
|
|
let applied = { encodings: [] } as unknown as RTCRtpSendParameters;
|
|
const sender = {
|
|
getParameters: () => applied,
|
|
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
|
|
applied = parameters;
|
|
}),
|
|
} as unknown as RTCRtpSender;
|
|
|
|
await expect(configureScreenAudioSender(sender)).resolves.toBe(128_000);
|
|
});
|
|
|
|
it("reports fields the browser does not retain", async () => {
|
|
const before = { encodings: [{}] } as RTCRtpSendParameters;
|
|
const after = {
|
|
encodings: [{ maxBitrate: 8_000_000 }],
|
|
degradationPreference: "maintain-resolution",
|
|
} as unknown as RTCRtpSendParameters;
|
|
const getParameters = vi
|
|
.fn<() => RTCRtpSendParameters>()
|
|
.mockReturnValueOnce(before)
|
|
.mockReturnValue(after);
|
|
const sender = {
|
|
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
|
|
getParameters,
|
|
setParameters: vi.fn(async () => undefined),
|
|
} as unknown as RTCRtpSender;
|
|
|
|
const readback = await configureVideoSender(
|
|
sender,
|
|
QUALITY_PROFILES["1080p60"],
|
|
);
|
|
|
|
expect(readback.mismatches).toEqual([
|
|
"maxFramerate",
|
|
"scaleResolutionDownBy",
|
|
"degradationPreference",
|
|
]);
|
|
expect(senderParameterWarning(readback)).toContain("帧率上限");
|
|
expect(senderParameterWarning(readback)).toContain("分辨率缩放");
|
|
expect(senderParameterWarning(readback)).toContain("质量优先级");
|
|
});
|
|
|
|
it("reports an applied default scalability mode without claiming a request", async () => {
|
|
const before = {
|
|
encodings: [{}],
|
|
} as unknown as RTCRtpSendParameters;
|
|
const after = {
|
|
encodings: [
|
|
{
|
|
maxBitrate: 8_000_000,
|
|
maxFramerate: 60,
|
|
scaleResolutionDownBy: 1,
|
|
scalabilityMode: "L1T2",
|
|
},
|
|
],
|
|
degradationPreference: "balanced",
|
|
} as unknown as RTCRtpSendParameters;
|
|
const sender = {
|
|
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
|
|
getParameters: vi
|
|
.fn<() => RTCRtpSendParameters>()
|
|
.mockReturnValueOnce(before)
|
|
.mockReturnValue(after),
|
|
setParameters: vi.fn(async () => undefined),
|
|
} as unknown as RTCRtpSender;
|
|
|
|
const readback = await configureVideoSender(
|
|
sender,
|
|
QUALITY_PROFILES["1080p60"],
|
|
);
|
|
|
|
expect(readback.requested.scalabilityMode).toBeNull();
|
|
expect(readback.applied.scalabilityMode).toBe("L1T2");
|
|
expect(readback.mismatches).not.toContain("scalabilityMode");
|
|
expect(senderParameterWarning(readback)).toBeNull();
|
|
});
|
|
|
|
it("keeps scalability mode unknown when sender encodings are ambiguous", async () => {
|
|
const before = { encodings: [{}] } as RTCRtpSendParameters;
|
|
const after = {
|
|
encodings: [
|
|
{
|
|
rid: "low",
|
|
maxBitrate: 8_000_000,
|
|
maxFramerate: 60,
|
|
scaleResolutionDownBy: 1,
|
|
scalabilityMode: "L1T1",
|
|
},
|
|
{ rid: "high", scalabilityMode: "L1T2" },
|
|
],
|
|
degradationPreference: "balanced",
|
|
} as unknown as RTCRtpSendParameters;
|
|
const sender = {
|
|
track: { getSettings: () => ({ width: 1920, height: 1080 }) },
|
|
getParameters: vi
|
|
.fn<() => RTCRtpSendParameters>()
|
|
.mockReturnValueOnce(before)
|
|
.mockReturnValue(after),
|
|
setParameters: vi.fn(async () => undefined),
|
|
} as unknown as RTCRtpSender;
|
|
|
|
const readback = await configureVideoSender(
|
|
sender,
|
|
QUALITY_PROFILES["1080p60"],
|
|
);
|
|
|
|
expect(readback.applied.scalabilityMode).toBeNull();
|
|
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();
|
|
|
|
expect(setMediaPaused(stream, true)).toBe(true);
|
|
expect(videoTrack.enabled).toBe(false);
|
|
expect(audioTrack.enabled).toBe(false);
|
|
expect(setMediaPaused(stream, false)).toBe(true);
|
|
expect(videoTrack.enabled).toBe(true);
|
|
expect(audioTrack.enabled).toBe(true);
|
|
});
|
|
});
|