Files
frigate/web/e2e/specs/settings/camera-live-transcode.spec.ts
T
Josh Hawkins cddd17f36e add transcoded live streams and stream ordering for auto mode
Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.

The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.

Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.

Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.
2026-09-26 13:26:27 -05:00

255 lines
8.1 KiB
TypeScript

/**
* Live playback stream order and transcoded streams -- MEDIUM tier.
*
* The stream list order is the Auto ladder, so the form saves it with
* replace_paths. Transcoded streams are generated from live.transcode and
* appear in the list as soon as the switch flips.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const GO2RTC_STREAMS = {
front_door_main: ["rtsp://user:pass@192.168.0.20:554/main"],
front_door_sub: ["rtsp://user:pass@192.168.0.20:554/sub"],
};
const TRANSCODE = {
enabled: false,
qualities: [
{ height: 720, bitrate: 1200 },
{ height: 480, bitrate: 500 },
],
};
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
type RouteOptions = {
go2rtcStreams?: Record<string, string[]>;
streams?: Record<string, string>;
bitrates?: Record<string, number>;
};
async function installRoutes(page: Page, options: RouteOptions = {}) {
const go2rtcStreams = options.go2rtcStreams ?? GO2RTC_STREAMS;
const streams = options.streams ?? {
Sub: "front_door_sub",
Main: "front_door_main",
};
const live = { streams, transcode: TRANSCODE };
const config = configFactory({
go2rtc: { streams: go2rtcStreams },
cameras: { front_door: { live } },
});
// configFactory's deepMerge adds override keys onto the base fixture's
// streams map instead of replacing it (they share no key names here), so
// pin the streams map to exactly what this test wants.
config.cameras.front_door.live.streams = streams;
let lastSaved: Record<string, unknown> | null = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
go2rtc: { streams: go2rtcStreams },
cameras: { front_door: { live } },
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSaved = route.request().postDataJSON();
await route.fulfill({ json: { success: true, go2rtc_synced: true } });
});
await page.route("**/api/go2rtc/streams/*/bitrate", (route) => {
const name = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").slice(-2)[0],
);
const kbps = options.bitrates?.[name];
return kbps === undefined
? route.fulfill({ status: 502, json: { success: false } })
: route.fulfill({ json: { success: true, kbps } });
});
return { saved: () => lastSaved };
}
function streamNames(page: Page) {
return page
.getByRole("textbox", { name: "Stream name" })
.evaluateAll((inputs) =>
inputs.map((input) => (input as HTMLInputElement).value),
);
}
async function dragRow(page: Page, from: number, to: number) {
const handles = page.getByRole("button", { name: "Drag to reorder" });
const start = await handles.nth(from).boundingBox();
const end = await handles.nth(to).boundingBox();
if (!start || !end) throw new Error("drag handle not visible");
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2);
await page.mouse.down();
await page.mouse.move(
end.x + end.width / 2,
end.y + end.height / 2 + (to > from ? 12 : -12),
{ steps: 15 },
);
await page.mouse.up();
}
test.describe("live stream order and transcoded streams @medium", () => {
test.skip(({ frigateApp }) => frigateApp.isMobile, "desktop drag");
test("dragging a stream saves the new order", async ({ frigateApp }) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Sub", "Main"]);
await dragRow(frigateApp.page, 1, 0);
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Main", "Sub"]);
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => capture.saved()).not.toBeNull();
const saved = capture.saved() as {
replace_paths: string[];
config_data: {
cameras: { front_door: { live: { streams: Record<string, string> } } };
};
};
expect(saved.replace_paths).toEqual(["cameras.front_door.live.streams"]);
expect(
Object.keys(saved.config_data.cameras.front_door.live.streams),
).toEqual(["Main", "Sub"]);
});
test("enabling transcoding saves the shown source", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page
.getByRole("switch", { name: "Enable transcoded streams" })
.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => capture.saved()).not.toBeNull();
const saved = capture.saved() as {
config_data: {
cameras: {
front_door: { live: { transcode: Record<string, unknown> } };
};
};
};
// pinned, so dragging another stream to the top can't change it
expect(saved.config_data.cameras.front_door.live.transcode).toMatchObject({
enabled: true,
source: "front_door_sub",
});
});
test("the transcode switch adds and removes transcoded rows", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const toggle = frigateApp.page.getByRole("switch", {
name: "Enable transcoded streams",
});
const save = frigateApp.page.getByRole("button", { name: "Save" });
await toggle.click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Sub", "Main", "720p", "480p"]);
await expect(
frigateApp.page.getByText("Transcoded", { exact: true }),
).toHaveCount(2);
await expect(save).toBeEnabled();
await toggle.click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Sub", "Main"]);
await expect(save).toBeDisabled();
});
test("auto order sorts measured and set bitrates", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {
bitrates: { front_door_main: 4000, front_door_sub: 800 },
});
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page
.getByRole("switch", { name: "Enable transcoded streams" })
.click();
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Main", "720p", "Sub", "480p"]);
await expect(frigateApp.page.getByText("4000 kbps")).toBeVisible();
await expect(frigateApp.page.getByText("1200 kbps (set)")).toBeVisible();
});
test("an unmeasurable stream sorts last", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {
bitrates: { front_door_main: 4000 },
});
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page.getByRole("button", { name: "Auto order" }).click();
await expect
.poll(() => streamNames(frigateApp.page))
.toEqual(["Main", "Sub"]);
await expect(frigateApp.page.getByText("Couldn't measure")).toBeVisible();
});
test("transcoding needs a go2rtc stream", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {
go2rtcStreams: {},
streams: { front_door: "front_door" },
});
await frigateApp.goto(SETTINGS_URL);
await expect(
frigateApp.page.getByRole("switch", {
name: "Enable transcoded streams",
}),
).toBeDisabled();
await expect(
frigateApp.page.getByText(/need a go2rtc stream to read from/),
).toBeVisible();
});
});