225 lines
6.8 KiB
TypeScript
225 lines
6.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
applyCaptureProfile,
|
|
captureDisplay,
|
|
configureVideoSender,
|
|
QUALITY_PROFILES,
|
|
senderParameterWarning,
|
|
setVideoPaused,
|
|
} 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 stream = {
|
|
getTracks: () => [videoTrack],
|
|
getVideoTracks: () => [videoTrack],
|
|
getAudioTracks: () => [],
|
|
} as unknown as MediaStream;
|
|
return { stream, videoTrack };
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("realtime quality controls", () => {
|
|
it("bounds initial capture to the selected profile", async () => {
|
|
const { stream, videoTrack } = 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: true,
|
|
});
|
|
expect(videoTrack.contentHint).toBe("motion");
|
|
});
|
|
|
|
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 clarity-first degradation", () => {
|
|
expect(
|
|
Object.values(QUALITY_PROFILES).every(
|
|
(profile) =>
|
|
profile.degradationPreference === "maintain-resolution",
|
|
),
|
|
).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: "maintain-resolution",
|
|
scalabilityMode: null,
|
|
},
|
|
applied: {
|
|
maxBitrate: 8_000_000,
|
|
maxFramerate: 60,
|
|
scaleResolutionDownBy: 4 / 3,
|
|
degradationPreference: "maintain-resolution",
|
|
scalabilityMode: null,
|
|
},
|
|
mismatches: [],
|
|
});
|
|
expect(senderParameterWarning(readback)).toBeNull();
|
|
expect(setParameters).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reports fields the browser does not retain", async () => {
|
|
const before = { encodings: [{}] } as RTCRtpSendParameters;
|
|
const after = {
|
|
encodings: [{ maxBitrate: 8_000_000 }],
|
|
degradationPreference: "balanced",
|
|
} 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: "maintain-resolution",
|
|
} 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: "maintain-resolution",
|
|
} 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("pauses only the video track and can resume it", () => {
|
|
const { stream, videoTrack } = createVideoStream();
|
|
|
|
expect(setVideoPaused(stream, true)).toBe(true);
|
|
expect(videoTrack.enabled).toBe(false);
|
|
expect(setVideoPaused(stream, false)).toBe(true);
|
|
expect(videoTrack.enabled).toBe(true);
|
|
});
|
|
});
|