Files
Piik/tests/quality.test.ts
AAA 95cc11f2c1 fix(media): restore capture recovery and forwarding cadence
Browser display capture could remain low-resolution after a temporary bandwidth reduction because sender adaptation also reduced the shared capture input. The encoded-output carrier could separately reduce frame rate after the real producer had already adapted, causing repeated queue recovery.

Keep display capture and sender feedback separate through bounded native frame streams, retain capture-owned constraints and metadata, and preserve carrier cadence. Existing WebRTC picture adaptation and transport ownership remain in place. Unsupported frame APIs retain ordinary sender tracks.

Real display acceptance also exposed an existing VP8 forwarding defect: a 15 fps L1T3 stream reached the SFU subscriber at about 4 fps because both shared forwarding owners overrode LiveKit's temporal limit with T0. Removing those overrides restores the library's layer selection for SFU and Native forwarding.

## Validation

- Web type checking, 1,186 tests and production build passed.
- Go tests, formatting, vet, Windows/Linux builds and Windows native capture checks passed; macOS packaging runs in the existing native CI job.
- H.264/VP8 real-tab first-frame, live/paused settings, source replacement, failure retirement and two-Viewer shaped-network checks passed.
- Real display through embedded SFU passed H.264 and VP8: hidden Host, two decoded spatial layers, resumed delivery, track replacement, profile updates, subscription recovery and full cleanup. VP8 decoded cadence recovered from about 4 to 15 fps. Track replacement uses the same visual source, and these checks do not measure A/V synchronization or physical mobile backgrounding.
- Shared RTP tests cover all three VP8 temporal layers through Transport and Publication; restoring the old T0 caps makes both tests fail.
- Documentation and repository hygiene checks passed.

## Release notes

### 中文

这是一次浏览器分享稳定性修复更新,重点改善网络波动后的画质恢复和编码复用时的画面流畅度。

- 修复部分 Chromium 浏览器屏幕共享降画质后,采集输入持续停留在低分辨率的问题。
- 修复编码复用路径重复降低帧率,导致画面卡顿和反复请求恢复帧的问题。
- 修复部分 VP8 画面经过媒体服务器或 App 转发时,帧率被错误限制在最低时间层的问题。
- 网页用户请在站点更新后刷新页面并重新开始分享;App 用户请更新并重启 App。

复杂画面的恢复速度和不同设备、网络下的短时清晰度波动仍在继续验证,本次更新不代表所有画质波动都已解决。

### English

This maintenance update improves browser sharing stability, focusing on picture recovery after network fluctuations and smoother delivery when encoded frames are reused.

- Fixed an issue in some Chromium browsers where display capture could remain at a low resolution after the sender reduced picture quality.
- Fixed repeated frame-rate reduction in the encoding-reuse path that could cause stuttering and repeated recovery-frame requests.
- Fixed an unintended frame-rate limit when VP8 video with temporal layers passed through the media server or Piik App's forwarding path.
- Web users should refresh the page and restart sharing after their site is updated. App users should update and restart Piik App.

Recovery with complex content and brief quality fluctuations across different devices and networks remain under investigation. This update does not claim to eliminate every quality fluctuation.
2026-09-30 20:15:52 +08:00

490 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
audioSenderParameterWarning,
applyCaptureProfile,
applyVideoCaptureProfile,
captureDisplay,
configureScreenAudioSender,
configureVideoSender,
matchingQualityProfileId,
needsStartupVideoProfile,
QUALITY_PROFILES,
QUALITY_RESOLUTIONS,
qualitySettingsEqual,
SCREEN_AUDIO_BITRATES,
senderParameterWarning,
setMediaPaused,
startupVideoProfile,
} from "../src/client/media/quality.ts";
import { setCopy } from "../src/client/ui/copy.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 };
}
beforeEach(() => {
setCopy({ lang: "zh", vis: false });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("realtime quality controls", () => {
it("keeps exactly three recommended profiles with 1080p30 as the default", () => {
expect(Object.keys(QUALITY_PROFILES)).toEqual([
"720p30",
"1080p30",
"1080p60",
]);
expect(QUALITY_PROFILES["1080p30"]).toMatchObject({
resolution: "1080p",
maxFramerate: 30,
maxBitrate: 5_000_000,
});
});
it("offers 854x480 only through advanced resolution settings", () => {
expect(QUALITY_RESOLUTIONS["480p"]).toEqual({
width: 854,
height: 480,
label: "480p",
});
expect(
Object.values(QUALITY_PROFILES).map((profile) => profile.resolution),
).not.toContain("480p");
});
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("leaves capture constraints with the owner of a remote track", async () => {
const { videoTrack } = createVideoStream();
videoTrack.getCapabilities = vi.fn(() => ({}));
await applyVideoCaptureProfile(videoTrack, QUALITY_PROFILES["1080p30"]);
expect(videoTrack.applyConstraints).not.toHaveBeenCalled();
});
it("defaults every recommended profile to balanced", () => {
expect(QUALITY_PROFILES["1080p30"].degradationPreference).toBe("balanced");
expect(QUALITY_PROFILES["1080p60"].degradationPreference).toBe("balanced");
expect(QUALITY_PROFILES["720p30"].degradationPreference).toBe("balanced");
});
it("holds resolution only while a scaling preference starts", () => {
expect(needsStartupVideoProfile(QUALITY_PROFILES["1080p30"])).toBe(true);
expect(startupVideoProfile(QUALITY_PROFILES["1080p30"])).toMatchObject({
degradationPreference: "maintain-resolution",
maxBitrate: 5_000_000,
maxFramerate: 30,
});
const clarity = {
...QUALITY_PROFILES["1080p30"],
degradationPreference: "maintain-resolution",
} as const;
expect(needsStartupVideoProfile(clarity)).toBe(false);
expect(startupVideoProfile(clarity)).toBe(clarity);
});
it("keeps audio selection orthogonal while defaulting missing 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("applies a profile to the highest-resolution simulcast encoding", async () => {
let applied = {
encodings: [
{
rid: "q",
maxBitrate: 1_250_000,
maxFramerate: 30,
scaleResolutionDownBy: 2,
},
{
rid: "h",
maxBitrate: 5_000_000,
maxFramerate: 30,
scaleResolutionDownBy: 1,
},
],
} as unknown as RTCRtpSendParameters;
const sender = {
track: {
getSettings: () => ({ width: 1920, height: 1080 }),
},
getParameters: () => applied,
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
applied = parameters;
}),
} as unknown as RTCRtpSender;
await expect(
configureVideoSender(sender, QUALITY_PROFILES["1080p60"]),
).resolves.toMatchObject({
requested: { maxBitrate: 8_000_000, maxFramerate: 60 },
applied: { maxBitrate: 8_000_000, maxFramerate: 60 },
mismatches: [],
});
expect(applied.encodings).toEqual([
{
rid: "q",
maxBitrate: 1_250_000,
maxFramerate: 30,
scaleResolutionDownBy: 2,
},
{
rid: "h",
maxBitrate: 8_000_000,
maxFramerate: 60,
scaleResolutionDownBy: 1,
},
]);
});
it("leaves picture cadence with the producer and restores ordinary sender adaptation", async () => {
let applied = { encodings: [{ maxFramerate: 30 }] } as RTCRtpSendParameters;
const sender = {
getParameters: () => applied,
setParameters: async (parameters: RTCRtpSendParameters) => { applied = parameters; },
} as unknown as RTCRtpSender;
const carrier = await configureVideoSender(sender, QUALITY_PROFILES["1080p60"], 1);
expect(applied.encodings[0]).not.toHaveProperty("maxFramerate");
expect(carrier).toMatchObject({ requested: { maxFramerate: null, maxBitrate: 8_000_000,
degradationPreference: "maintain-framerate" }, mismatches: [] });
for (const id of ["720p30", "1080p60"] as const) {
const readback = await configureVideoSender(sender, QUALITY_PROFILES[id]);
expect(applied.encodings[0]!.maxFramerate).toBe(QUALITY_PROFILES[id].maxFramerate);
expect(applied.degradationPreference).toBe(QUALITY_PROFILES[id].degradationPreference);
expect(readback.mismatches).toEqual([]);
}
});
it("does not scale a remote source that already owns the room profile", async () => {
let applied = { encodings: [{}] } as unknown as RTCRtpSendParameters;
const sender = {
track: {
getCapabilities: () => ({}),
getSettings: () => ({ width: 2560, height: 1440 }),
},
getParameters: () => applied,
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
applied = parameters;
}),
} as unknown as RTCRtpSender;
const readback = await configureVideoSender(
sender,
QUALITY_PROFILES["720p30"],
);
expect(readback.requested.scaleResolutionDownBy).toBe(1);
expect(applied.encodings[0]?.scaleResolutionDownBy).toBe(1);
});
it.each([
["saver", 64_000],
["music", 128_000],
["very-high", 192_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.toEqual({
requestedMaxBitrate: bitrate,
appliedMaxBitrate: bitrate,
mismatch: false,
});
expect(applied.encodings).toEqual([{ maxBitrate: bitrate }]);
expect(SCREEN_AUDIO_BITRATES[quality]).toBe(bitrate);
});
it("uses the music ceiling for a 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.toEqual({
requestedMaxBitrate: 128_000,
appliedMaxBitrate: 128_000,
mismatch: false,
});
});
it("reports an audio ceiling the browser rewrites", async () => {
const sender = {
getParameters: vi
.fn<() => RTCRtpSendParameters>()
.mockReturnValueOnce({ encodings: [{}] } as RTCRtpSendParameters)
.mockReturnValue({
encodings: [{ maxBitrate: 96_000 }],
} as RTCRtpSendParameters),
setParameters: vi.fn(async () => undefined),
} as unknown as RTCRtpSender;
const readback = await configureScreenAudioSender(sender, "music");
expect(readback).toEqual({
requestedMaxBitrate: 128_000,
appliedMaxBitrate: 96_000,
mismatch: true,
});
expect(audioSenderParameterWarning(readback)).toEqual({
key: "host.warn.audioRewritten",
vars: { kbps: "96" },
});
});
it("does not mutate prior audio parameters when setParameters fails", async () => {
const applied = {
encodings: [{ maxBitrate: 128_000 }],
} as RTCRtpSendParameters;
const sender = {
getParameters: () => applied,
setParameters: vi.fn(async () => {
throw new Error("rejected");
}),
} as unknown as RTCRtpSender;
await expect(
configureScreenAudioSender(sender, "very-high"),
).rejects.toThrow("rejected");
expect(applied.encodings[0]?.maxBitrate).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)).toEqual({
key: "host.warn.senderPartial",
paramKeys: [
"host.warn.param.maxFramerate",
"host.warn.param.scaleResolutionDownBy",
"host.warn.param.degradationPreference",
],
});
});
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("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);
});
});