The same maxBitrate was written into every encoding, so a 1.5 Mbps cap could mean 4.5 Mbps aggregate. Distribute proportionally to the layers' existing ratios (floor, so never over). Tested with a 3-layer case. Fixes #12 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
/*
|
|
Copyright 2026 Lotus Guild
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { describe, expect, test, vi } from "vitest";
|
|
|
|
import {
|
|
applyProportionalMaxBitrate,
|
|
buildPatch,
|
|
patchSender,
|
|
} from "./lotusQuality";
|
|
|
|
function makeSender(
|
|
initialEncodings: RTCRtpEncodingParameters[] = [{}],
|
|
): RTCRtpSender {
|
|
let encodings = initialEncodings;
|
|
return {
|
|
getParameters: vi.fn(() => ({ encodings })),
|
|
setParameters: vi.fn(async (params: RTCRtpSendParameters) => {
|
|
await Promise.resolve();
|
|
encodings = params.encodings ?? [];
|
|
}),
|
|
} as unknown as RTCRtpSender;
|
|
}
|
|
|
|
describe("lotusQuality set_quality -> clear (#11)", () => {
|
|
test("clearing a previously-set cap actively unsets it on the sender", async () => {
|
|
const sender = makeSender();
|
|
const writtenKeys = new WeakMap<
|
|
RTCRtpSender,
|
|
Set<keyof RTCRtpEncodingParameters>
|
|
>();
|
|
|
|
// Set: audioMaxBitrate = 64000.
|
|
const setPatch = buildPatch(sender, { maxBitrate: 64_000 }, writtenKeys);
|
|
expect(setPatch).toEqual({ maxBitrate: 64_000 });
|
|
await patchSender(sender, setPatch, writtenKeys);
|
|
|
|
expect(sender.getParameters().encodings[0].maxBitrate).toBe(64_000);
|
|
expect(writtenKeys.get(sender)).toEqual(new Set(["maxBitrate"]));
|
|
|
|
// Clear: host sends `null`, so the caller now wants an empty desired
|
|
// patch. buildPatch must still emit an explicit `undefined` for the key
|
|
// it previously wrote, instead of an empty patch that leaves the stale
|
|
// cap on the sender.
|
|
const clearPatch = buildPatch(sender, {}, writtenKeys);
|
|
expect(clearPatch).toEqual({ maxBitrate: undefined });
|
|
await patchSender(sender, clearPatch, writtenKeys);
|
|
|
|
const finalEncoding = sender.getParameters().encodings[0];
|
|
expect(finalEncoding.maxBitrate).toBeUndefined();
|
|
expect("maxBitrate" in finalEncoding).toBe(true); // explicitly cleared, not merely absent
|
|
expect(writtenKeys.has(sender)).toBe(false);
|
|
});
|
|
|
|
test("clearing one of several caps leaves the others (and their tracking) intact", async () => {
|
|
const sender = makeSender();
|
|
const writtenKeys = new WeakMap<
|
|
RTCRtpSender,
|
|
Set<keyof RTCRtpEncodingParameters>
|
|
>();
|
|
|
|
const setPatch = buildPatch(
|
|
sender,
|
|
{ maxBitrate: 500_000, maxFramerate: 24 },
|
|
writtenKeys,
|
|
);
|
|
await patchSender(sender, setPatch, writtenKeys);
|
|
expect(writtenKeys.get(sender)).toEqual(
|
|
new Set(["maxBitrate", "maxFramerate"]),
|
|
);
|
|
|
|
// Only maxFramerate is still desired; maxBitrate should be actively
|
|
// cleared.
|
|
const clearPatch = buildPatch(sender, { maxFramerate: 24 }, writtenKeys);
|
|
expect(clearPatch).toEqual({ maxFramerate: 24, maxBitrate: undefined });
|
|
await patchSender(sender, clearPatch, writtenKeys);
|
|
|
|
const finalEncoding = sender.getParameters().encodings[0];
|
|
expect(finalEncoding.maxBitrate).toBeUndefined();
|
|
expect(finalEncoding.maxFramerate).toBe(24);
|
|
expect(writtenKeys.get(sender)).toEqual(new Set(["maxFramerate"]));
|
|
});
|
|
|
|
test("no sender means an empty patch and no call", () => {
|
|
const writtenKeys = new WeakMap<
|
|
RTCRtpSender,
|
|
Set<keyof RTCRtpEncodingParameters>
|
|
>();
|
|
expect(buildPatch(undefined, { maxBitrate: 1000 }, writtenKeys)).toEqual(
|
|
{},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("lotusQuality screenshare simulcast cap (#12)", () => {
|
|
test("caps the aggregate across a 3-layer simulcast publication, not per-layer", async () => {
|
|
// A typical VP8 simulcast screenshare publication: low/medium/high
|
|
// layers with existing presets, ordered low-to-high like LiveKit
|
|
// publishes them.
|
|
const sender = makeSender([
|
|
{ maxBitrate: 150_000 },
|
|
{ maxBitrate: 500_000 },
|
|
{ maxBitrate: 1_500_000 },
|
|
]);
|
|
const writtenKeys = new WeakMap<
|
|
RTCRtpSender,
|
|
Set<keyof RTCRtpEncodingParameters>
|
|
>();
|
|
|
|
const cap = 750_000;
|
|
const patch = buildPatch(sender, { maxBitrate: cap }, writtenKeys);
|
|
await patchSender(sender, patch, writtenKeys);
|
|
|
|
const encodings = sender.getParameters().encodings;
|
|
expect(encodings).toHaveLength(3);
|
|
// Each layer keeps its old proportion of the total, but the aggregate
|
|
// across all layers must not exceed the requested cap — that's the bug:
|
|
// writing `cap` into every layer let the aggregate reach ~3x the cap.
|
|
const oldTotal = 150_000 + 500_000 + 1_500_000;
|
|
expect(encodings[0].maxBitrate).toBe(
|
|
Math.floor((150_000 / oldTotal) * cap),
|
|
);
|
|
expect(encodings[1].maxBitrate).toBe(
|
|
Math.floor((500_000 / oldTotal) * cap),
|
|
);
|
|
expect(encodings[2].maxBitrate).toBe(
|
|
Math.floor((1_500_000 / oldTotal) * cap),
|
|
);
|
|
// The highest layer still ends up with the largest share.
|
|
expect(encodings[2].maxBitrate).toBeGreaterThan(encodings[1].maxBitrate!);
|
|
expect(encodings[1].maxBitrate).toBeGreaterThan(encodings[0].maxBitrate!);
|
|
|
|
const aggregate = encodings.reduce(
|
|
(sum, enc) => sum + (enc.maxBitrate ?? 0),
|
|
0,
|
|
);
|
|
expect(aggregate).toBeLessThanOrEqual(cap);
|
|
});
|
|
|
|
test("a single encoding keeps the previous (non-scaled) behaviour", () => {
|
|
const encodings: RTCRtpEncodingParameters[] = [{ maxBitrate: 2_000_000 }];
|
|
applyProportionalMaxBitrate(encodings, 500_000);
|
|
expect(encodings).toEqual([{ maxBitrate: 500_000 }]);
|
|
});
|
|
|
|
test("falls back to capping only the highest layer when there are no prior ratios", () => {
|
|
const encodings: RTCRtpEncodingParameters[] = [
|
|
{}, // no prior cap on any layer to derive a ratio from
|
|
{},
|
|
];
|
|
applyProportionalMaxBitrate(encodings, 500_000);
|
|
expect(encodings[0].maxBitrate).toBeUndefined();
|
|
expect(encodings[1].maxBitrate).toBe(500_000);
|
|
});
|
|
});
|