mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
Add Apple compatibility switch to the camera wizard (#24115)
* add apple compatibility switch to the camera wizard * don't require every record stream to be h265
This commit is contained in:
parent
bb1e556ba9
commit
e99eb77ca1
353
web/e2e/specs/settings/camera-wizard-apple-compatibility.spec.ts
Normal file
353
web/e2e/specs/settings/camera-wizard-apple-compatibility.spec.ts
Normal file
@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Add-camera wizard - Apple/HEVC compatibility switch on Step 3.
|
||||
*
|
||||
* It writes the camera-level `ffmpeg.apple_compatibility` and starts on for
|
||||
* Apple browsers. That default is user-agent driven, so the second describe
|
||||
* pins an explicit Safari and Chrome UA instead of relying on the project's
|
||||
* own.
|
||||
*
|
||||
* The save tests drive Step 4, which registers go2rtc streams and renders MSE
|
||||
* previews; they mock those and assert only the captured config/set body.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
|
||||
const MAIN_URI = "rtsp://admin:pw@192.168.1.100:554/stream1";
|
||||
const SUB_URI = "rtsp://admin:pw@192.168.1.100:554/stream2";
|
||||
|
||||
const SAFARI_UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
const CHROME_UA =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
const APPLE_TITLE = "Improve playback on Apple devices";
|
||||
|
||||
const PROBE = {
|
||||
success: true,
|
||||
host: "192.168.1.100",
|
||||
port: 80,
|
||||
manufacturer: "Acme",
|
||||
model: "Cam-1",
|
||||
firmware_version: "1.0",
|
||||
profiles_count: 2,
|
||||
ptz_supported: false,
|
||||
pan_tilt_supported: false,
|
||||
presets_count: 0,
|
||||
autotrack_supported: false,
|
||||
rtsp_candidates: [
|
||||
{ source: "GetStreamUri", profile_token: "profile_1", uri: MAIN_URI },
|
||||
{ source: "GetStreamUri", profile_token: "profile_2", uri: SUB_URI },
|
||||
],
|
||||
};
|
||||
|
||||
function ffprobeJson(codec: string) {
|
||||
return [
|
||||
{
|
||||
return_code: 0,
|
||||
stderr: [],
|
||||
stdout: {
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: codec,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
avg_frame_rate: "15/1",
|
||||
},
|
||||
{ codec_type: "audio", codec_name: "aac" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Mock ffprobe per stream URL; a null codec makes that probe fail. */
|
||||
async function mockFfprobe(
|
||||
page: Page,
|
||||
codecByUri: Record<string, string | null>,
|
||||
) {
|
||||
await page.route("**/api/ffprobe**", (route) => {
|
||||
const paths = new URL(route.request().url()).searchParams.get("paths");
|
||||
const match = Object.keys(codecByUri).find((uri) => paths?.includes(uri));
|
||||
const codec = match ? codecByUri[match] : null;
|
||||
return route.fulfill({
|
||||
json: codec
|
||||
? ffprobeJson(codec)
|
||||
: [{ return_code: 1, stderr: ["probe failed"], stdout: "" }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the wizard and drive Step 1 -> Step 2 -> Step 3. */
|
||||
async function gotoStep3(page: Page) {
|
||||
await page.route("**/api/onvif/probe**", (route) =>
|
||||
route.fulfill({ json: PROBE }),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByPlaceholder(/front_door/i).fill("hevc_test_camera");
|
||||
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
||||
await dialog.getByRole("button", { name: /^Continue$/i }).click();
|
||||
|
||||
const next = dialog.getByRole("button", { name: /^Next$/i });
|
||||
await expect(next).toBeEnabled({ timeout: 10_000 });
|
||||
await next.click();
|
||||
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Add Another Stream/i }),
|
||||
).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/** The role toggle for `role` on the nth stream card (0-based). */
|
||||
function roleSwitch(dialog: Locator, role: string, streamIndex = 0) {
|
||||
return dialog
|
||||
.locator("span.capitalize", { hasText: new RegExp(`^${role}$`) })
|
||||
.nth(streamIndex)
|
||||
.locator("xpath=..")
|
||||
.getByRole("switch");
|
||||
}
|
||||
|
||||
/** Run "Test Connection" on the nth stream card and wait for the result. */
|
||||
async function testStream(dialog: Locator, streamIndex = 0) {
|
||||
await dialog
|
||||
.getByRole("button", { name: /Test Connection/i })
|
||||
.nth(streamIndex)
|
||||
.click();
|
||||
await expect(
|
||||
dialog.getByText("Connected", { exact: true }).nth(streamIndex),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Run "Test Connection" on the nth stream card and wait for it to fail. */
|
||||
async function failStream(dialog: Locator, streamIndex: number) {
|
||||
await dialog
|
||||
.getByRole("button", { name: /Test Connection/i })
|
||||
.nth(streamIndex)
|
||||
.click();
|
||||
await expect(dialog.getByText("Test Failed", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
function appleSwitch(dialog: Locator) {
|
||||
return dialog
|
||||
.locator("div.items-start.justify-between", { hasText: APPLE_TITLE })
|
||||
.getByRole("switch");
|
||||
}
|
||||
|
||||
async function openCameraManagement(frigateApp: {
|
||||
page: Page;
|
||||
goto: (path: string) => Promise<void>;
|
||||
}) {
|
||||
// not in the default mock; unmocked it 500s and trips the error collector
|
||||
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraManagement");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Camera wizard Apple compatibility @medium @mobile", () => {
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
await openCameraManagement(frigateApp);
|
||||
});
|
||||
|
||||
test("appears only once the record stream is probed as H.265", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockFfprobe(frigateApp.page, { [MAIN_URI]: "hevc" });
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
// the probe leaves the stream untested, so the codec is unknown
|
||||
await roleSwitch(dialog, "record").click();
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(0);
|
||||
|
||||
await testStream(dialog);
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toBeVisible();
|
||||
});
|
||||
|
||||
test("stays hidden for an H.264 record stream", async ({ frigateApp }) => {
|
||||
await mockFfprobe(frigateApp.page, { [MAIN_URI]: "h264" });
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await roleSwitch(dialog, "record").click();
|
||||
await testStream(dialog);
|
||||
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("stays hidden for an H.265 stream with no recording role", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockFfprobe(frigateApp.page, { [MAIN_URI]: "hevc" });
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
// detect is assigned by default; no record or record_sub role
|
||||
await testStream(dialog);
|
||||
await expect(roleSwitch(dialog, "detect")).toBeChecked();
|
||||
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("appears for an H.265 record_sub stream", async ({ frigateApp }) => {
|
||||
await mockFfprobe(frigateApp.page, { [MAIN_URI]: "hevc" });
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await roleSwitch(dialog, "record_sub").click();
|
||||
await testStream(dialog);
|
||||
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders once when only one recording stream is H.265", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockFfprobe(frigateApp.page, {
|
||||
[MAIN_URI]: "hevc",
|
||||
[SUB_URI]: "h264",
|
||||
});
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await roleSwitch(dialog, "record").click();
|
||||
await testStream(dialog);
|
||||
|
||||
await dialog.getByRole("button", { name: /Add Another Stream/i }).click();
|
||||
await roleSwitch(dialog, "record_sub", 1).click();
|
||||
await testStream(dialog, 1);
|
||||
|
||||
// ffmpeg drops the tag on the H.264 output, so the H.265 one still wins
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("stays visible when another recording stream fails to probe", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await mockFfprobe(frigateApp.page, {
|
||||
[MAIN_URI]: "hevc",
|
||||
[SUB_URI]: null,
|
||||
});
|
||||
const dialog = await gotoStep3(frigateApp.page);
|
||||
|
||||
await roleSwitch(dialog, "record").click();
|
||||
await testStream(dialog);
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: /Add Another Stream/i }).click();
|
||||
await roleSwitch(dialog, "record_sub", 1).click();
|
||||
await failStream(dialog, 1);
|
||||
|
||||
await expect(dialog.getByText(APPLE_TITLE)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Camera wizard Apple compatibility default @medium @mobile", () => {
|
||||
test.describe("on an Apple browser", () => {
|
||||
test.use({ userAgent: SAFARI_UA });
|
||||
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
await openCameraManagement(frigateApp);
|
||||
});
|
||||
|
||||
test("starts on for an H.265 record stream and is saved", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const ffmpeg = await saveHevcCamera(frigateApp.page, {
|
||||
startsOn: true,
|
||||
toggle: false,
|
||||
});
|
||||
expect(ffmpeg.apple_compatibility).toBe(true);
|
||||
});
|
||||
|
||||
test("can still be turned off, which omits it from the save", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const ffmpeg = await saveHevcCamera(frigateApp.page, {
|
||||
startsOn: true,
|
||||
toggle: true,
|
||||
});
|
||||
expect(ffmpeg).not.toHaveProperty("apple_compatibility");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("on a non-Apple browser", () => {
|
||||
test.use({ userAgent: CHROME_UA });
|
||||
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
await openCameraManagement(frigateApp);
|
||||
});
|
||||
|
||||
test("starts off and is omitted so the global applies", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const ffmpeg = await saveHevcCamera(frigateApp.page, {
|
||||
startsOn: false,
|
||||
toggle: false,
|
||||
});
|
||||
expect(ffmpeg).not.toHaveProperty("apple_compatibility");
|
||||
});
|
||||
|
||||
test("can be turned on, which writes it at camera level", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const ffmpeg = await saveHevcCamera(frigateApp.page, {
|
||||
startsOn: false,
|
||||
toggle: true,
|
||||
});
|
||||
expect(ffmpeg.apple_compatibility).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive the whole wizard for an H.265 record stream, asserting the switch's
|
||||
* starting state and optionally toggling it, then return the `ffmpeg` section
|
||||
* of the camera that config/set received.
|
||||
*/
|
||||
async function saveHevcCamera(
|
||||
page: Page,
|
||||
{ startsOn, toggle }: { startsOn: boolean; toggle: boolean },
|
||||
) {
|
||||
await mockFfprobe(page, { [MAIN_URI]: "hevc" });
|
||||
|
||||
const saved: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/config/set", (route) => {
|
||||
saved.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { success: true, require_restart: false } });
|
||||
});
|
||||
|
||||
const dialog = await gotoStep3(page);
|
||||
await roleSwitch(dialog, "record").click();
|
||||
await testStream(dialog);
|
||||
|
||||
await expect(appleSwitch(dialog)).toBeChecked({ checked: startsOn });
|
||||
if (toggle) {
|
||||
await appleSwitch(dialog).click();
|
||||
await expect(appleSwitch(dialog)).toBeChecked({ checked: !startsOn });
|
||||
}
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
const save = dialog.getByRole("button", { name: /Save New Camera/i });
|
||||
await expect(save).toBeEnabled({ timeout: 15_000 });
|
||||
await save.click();
|
||||
|
||||
// the camera PUT is the one carrying update_topic; go2rtc follows without it
|
||||
await expect
|
||||
.poll(() => saved.some((body) => "update_topic" in body), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const cameraSave = saved.find((body) => "update_topic" in body) as {
|
||||
update_topic: string;
|
||||
config_data: {
|
||||
cameras: Record<string, { ffmpeg: { apple_compatibility?: boolean } }>;
|
||||
};
|
||||
};
|
||||
expect(cameraSave.update_topic).toBe("config/cameras/hevc_test_camera/add");
|
||||
return cameraSave.config_data.cameras.hevc_test_camera.ffmpeg;
|
||||
}
|
||||
@ -427,6 +427,10 @@
|
||||
"notConnected": "Not Connected",
|
||||
"featuresTitle": "Features",
|
||||
"go2rtc": "Reduce connections to camera",
|
||||
"appleCompatibility": {
|
||||
"title": "Improve playback on Apple devices",
|
||||
"description": "Turn this on if you watch recordings in Safari or on an iPhone, iPad, or Mac."
|
||||
},
|
||||
"detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.",
|
||||
"rolesPopover": {
|
||||
"title": "Stream Roles",
|
||||
|
||||
@ -23,6 +23,7 @@ import type {
|
||||
import {
|
||||
processCameraName,
|
||||
calculateDetectDimensions,
|
||||
hevcRecordingStreamId,
|
||||
} from "@/utils/cameraUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -185,6 +186,11 @@ export default function CameraWizardDialog({
|
||||
wizardData.cameraName,
|
||||
);
|
||||
|
||||
// re-checked here: roles and codecs may have changed since it was set
|
||||
const appleCompatibility =
|
||||
!!wizardData.appleCompatibility &&
|
||||
!!hevcRecordingStreamId(wizardData.streams);
|
||||
|
||||
// Convert wizard data to Frigate config format
|
||||
const configData: CameraConfigData = {
|
||||
cameras: {
|
||||
@ -192,6 +198,7 @@ export default function CameraWizardDialog({
|
||||
enabled: true,
|
||||
...(friendlyName && { friendly_name: friendlyName }),
|
||||
ffmpeg: {
|
||||
...(appleCompatibility && { apple_compatibility: true }),
|
||||
inputs: wizardData.streams.map((stream, index) => {
|
||||
if (stream.restream) {
|
||||
const go2rtcStreamName =
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isIOS, isMobile, isSafari } from "react-device-detect";
|
||||
import {
|
||||
LuInfo,
|
||||
LuExternalLink,
|
||||
@ -53,6 +53,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { hevcRecordingStreamId } from "@/utils/cameraUtil";
|
||||
|
||||
// Recording the sub stream from the same stream as record would just
|
||||
// re-record the main stream, so the two roles are mutually exclusive.
|
||||
@ -389,6 +390,22 @@ export default function Step3StreamConfig({
|
||||
|
||||
const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
|
||||
|
||||
const appleCompatibilityStreamId = useMemo(
|
||||
() => hevcRecordingStreamId(streams),
|
||||
[streams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// undefined, not false: a deliberate toggle-off must not be re-seeded
|
||||
if (
|
||||
(isSafari || isIOS) &&
|
||||
appleCompatibilityStreamId &&
|
||||
wizardData.appleCompatibility === undefined
|
||||
) {
|
||||
onUpdate({ appleCompatibility: true });
|
||||
}
|
||||
}, [appleCompatibilityStreamId, wizardData.appleCompatibility, onUpdate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-sm text-secondary-foreground">
|
||||
@ -778,7 +795,7 @@ export default function Step3StreamConfig({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="space-y-3 rounded-lg bg-background p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{t("cameraWizard.step3.go2rtc")}
|
||||
@ -788,6 +805,27 @@ export default function Step3StreamConfig({
|
||||
onCheckedChange={() => setRestream(stream.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{appleCompatibilityStreamId === stream.id && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm">
|
||||
{t("cameraWizard.step3.appleCompatibility.title")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"cameraWizard.step3.appleCompatibility.description",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={wizardData.appleCompatibility ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({ appleCompatibility: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -268,6 +268,7 @@ export default function Step4Validation({
|
||||
customUrl: wizardData.customUrl,
|
||||
streams: wizardData.streams,
|
||||
hasBackchannel: wizardData.hasBackchannel,
|
||||
appleCompatibility: wizardData.appleCompatibility,
|
||||
onvif: wizardData.onvif,
|
||||
};
|
||||
|
||||
|
||||
@ -119,6 +119,7 @@ export type WizardFormData = {
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
hasBackchannel?: boolean; // true if camera supports backchannel audio
|
||||
appleCompatibility?: boolean; // camera level, covers both recording outputs
|
||||
onvif?: {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@ -163,6 +164,7 @@ export type CameraConfigData = {
|
||||
enabled: boolean;
|
||||
friendly_name?: string;
|
||||
ffmpeg: {
|
||||
apple_compatibility?: boolean;
|
||||
inputs: {
|
||||
path: string;
|
||||
roles: string[];
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { generateFixedHash, isValidId } from "./stringUtil";
|
||||
import type { LiveStreamMetadata } from "@/types/live";
|
||||
import type { StreamConfig } from "@/types/cameraWizard";
|
||||
|
||||
/**
|
||||
* Processes a user-entered camera name and returns both the final camera name
|
||||
@ -205,3 +206,25 @@ const REPLAY_CAMERA_PREFIX = "_replay_";
|
||||
export function isReplayCamera(name: string): boolean {
|
||||
return name.startsWith(REPLAY_CAMERA_PREFIX);
|
||||
}
|
||||
|
||||
const HEVC_CODEC_NAMES = ["hevc", "h265"];
|
||||
|
||||
function isHevcCodec(codec?: string): boolean {
|
||||
return HEVC_CODEC_NAMES.includes((codec ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isRecordingStream(stream: StreamConfig): boolean {
|
||||
return stream.roles.includes("record") || stream.roles.includes("record_sub");
|
||||
}
|
||||
|
||||
/**
|
||||
* First recording stream probed as H.265. The other record output's codec
|
||||
* doesn't matter: ffmpeg drops `-tag:v hvc1` on anything that isn't HEVC.
|
||||
*/
|
||||
export function hevcRecordingStreamId(
|
||||
streams: StreamConfig[],
|
||||
): string | undefined {
|
||||
return streams.find(
|
||||
(s) => isRecordingStream(s) && isHevcCodec(s.testResult?.videoCodec),
|
||||
)?.id;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user