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",
|
"notConnected": "Not Connected",
|
||||||
"featuresTitle": "Features",
|
"featuresTitle": "Features",
|
||||||
"go2rtc": "Reduce connections to camera",
|
"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.",
|
"detectRoleWarning": "At least one stream must have the \"detect\" role to proceed.",
|
||||||
"rolesPopover": {
|
"rolesPopover": {
|
||||||
"title": "Stream Roles",
|
"title": "Stream Roles",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
processCameraName,
|
processCameraName,
|
||||||
calculateDetectDimensions,
|
calculateDetectDimensions,
|
||||||
|
hevcRecordingStreamId,
|
||||||
} from "@/utils/cameraUtil";
|
} from "@/utils/cameraUtil";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@ -185,6 +186,11 @@ export default function CameraWizardDialog({
|
|||||||
wizardData.cameraName,
|
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
|
// Convert wizard data to Frigate config format
|
||||||
const configData: CameraConfigData = {
|
const configData: CameraConfigData = {
|
||||||
cameras: {
|
cameras: {
|
||||||
@ -192,6 +198,7 @@ export default function CameraWizardDialog({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
...(friendlyName && { friendly_name: friendlyName }),
|
...(friendlyName && { friendly_name: friendlyName }),
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
|
...(appleCompatibility && { apple_compatibility: true }),
|
||||||
inputs: wizardData.streams.map((stream, index) => {
|
inputs: wizardData.streams.map((stream, index) => {
|
||||||
if (stream.restream) {
|
if (stream.restream) {
|
||||||
const go2rtcStreamName =
|
const go2rtcStreamName =
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isIOS, isMobile, isSafari } from "react-device-detect";
|
||||||
import {
|
import {
|
||||||
LuInfo,
|
LuInfo,
|
||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
@ -53,6 +53,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import { hevcRecordingStreamId } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
// Recording the sub stream from the same stream as record would just
|
// Recording the sub stream from the same stream as record would just
|
||||||
// re-record the main stream, so the two roles are mutually exclusive.
|
// 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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm text-secondary-foreground">
|
<div className="text-sm text-secondary-foreground">
|
||||||
@ -778,7 +795,7 @@ export default function Step3StreamConfig({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{t("cameraWizard.step3.go2rtc")}
|
{t("cameraWizard.step3.go2rtc")}
|
||||||
@ -788,6 +805,27 @@ export default function Step3StreamConfig({
|
|||||||
onCheckedChange={() => setRestream(stream.id)}
|
onCheckedChange={() => setRestream(stream.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -268,6 +268,7 @@ export default function Step4Validation({
|
|||||||
customUrl: wizardData.customUrl,
|
customUrl: wizardData.customUrl,
|
||||||
streams: wizardData.streams,
|
streams: wizardData.streams,
|
||||||
hasBackchannel: wizardData.hasBackchannel,
|
hasBackchannel: wizardData.hasBackchannel,
|
||||||
|
appleCompatibility: wizardData.appleCompatibility,
|
||||||
onvif: wizardData.onvif,
|
onvif: wizardData.onvif,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -119,6 +119,7 @@ export type WizardFormData = {
|
|||||||
probeCandidates?: string[]; // candidate URLs from probe
|
probeCandidates?: string[]; // candidate URLs from probe
|
||||||
candidateTests?: CandidateTestMap; // test results for candidates
|
candidateTests?: CandidateTestMap; // test results for candidates
|
||||||
hasBackchannel?: boolean; // true if camera supports backchannel audio
|
hasBackchannel?: boolean; // true if camera supports backchannel audio
|
||||||
|
appleCompatibility?: boolean; // camera level, covers both recording outputs
|
||||||
onvif?: {
|
onvif?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@ -163,6 +164,7 @@ export type CameraConfigData = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
friendly_name?: string;
|
friendly_name?: string;
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
|
apple_compatibility?: boolean;
|
||||||
inputs: {
|
inputs: {
|
||||||
path: string;
|
path: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { generateFixedHash, isValidId } from "./stringUtil";
|
import { generateFixedHash, isValidId } from "./stringUtil";
|
||||||
import type { LiveStreamMetadata } from "@/types/live";
|
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
|
* 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 {
|
export function isReplayCamera(name: string): boolean {
|
||||||
return name.startsWith(REPLAY_CAMERA_PREFIX);
|
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