resolve hwaccel per camera and clarify recording retention

The hwaccel step listed every preset Frigate ships, so an Intel box was offered Raspberry Pi and Rockchip decoding, and the codec specific presets (`preset-intel-qsv-h264` vs `-h265`) were offered as global values that break as soon as two cameras use different codecs. `/hardware/hwaccel` now returns the decoding families the probed hardware can actually use, each carrying a preset per codec, and the wizard resolves the family against the detect stream codec the camera wizard already probed: one global `ffmpeg.hwaccel_args` when every camera agrees, per-camera `cameras.<name>.ffmpeg.hwaccel_args` when they don't. The global stays on `auto` in that case so cameras added later still resolve at startup. A gen13+ Intel machine keeps its QuickSync recommendation with mixed h264 and h265 cameras instead of dropping to vaapi.

The recording step's "Days to retain recordings" only wrote alert and detection retention, and the storage estimate under it assumed continuous recording. It now asks what to record in plain language, writes `record.continuous.days` to match, shows the estimate only for continuous, and drops the spinner arrows on the number input.
This commit is contained in:
Josh Hawkins
2026-08-21 19:16:52 -05:00
parent cfda6baa06
commit 0a5d9e5126
13 changed files with 815 additions and 178 deletions
+11 -2
View File
@@ -43,7 +43,10 @@ export interface ApiMockOverrides {
configRaw?: string;
configSchema?: Record<string, unknown>;
hardware?: unknown[];
hwaccel?: { preset: string };
hwaccel?: {
recommended: string;
available?: { key: string; presets: Record<string, string> }[];
};
}
export class ApiMocker {
@@ -188,7 +191,13 @@ export class ApiMocker {
// Hwaccel preset recommendation
await this.page.route("**/api/hardware/hwaccel**", (route) =>
route.fulfill({ json: overrides?.hwaccel ?? { preset: "" } }),
route.fulfill({
json: {
recommended: "",
available: [],
...(overrides?.hwaccel ?? {}),
},
}),
);
// Go2RTC streams
+95 -4
View File
@@ -85,7 +85,19 @@ test.describe("setup wizard hardware @high @mobile", () => {
await frigateApp.installDefaults({
// the base config snapshot predates the onboarding key, hence the cast
config: { onboarding: { setup_complete: false } } as never,
hwaccel: { preset: "preset-vaapi" },
hwaccel: {
recommended: "vaapi",
available: [
{ key: "vaapi", presets: { any: "preset-vaapi" } },
{
key: "intel-qsv",
presets: {
h264: "preset-intel-qsv-h264",
h265: "preset-intel-qsv-h265",
},
},
],
},
});
const saves = await captureSaves(page);
@@ -109,8 +121,9 @@ test.describe("setup wizard hardware @high @mobile", () => {
]);
expect(detectorSave?.config_data?.detect).toEqual({ enabled: true });
// Auto shows the preset derived from the chosen hardware and writes it
await expect(page.getByText("preset-vaapi")).toBeVisible();
// Auto names the family derived from the chosen hardware and writes its
// preset; VAAPI decodes any codec, so one global value covers every camera
await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
@@ -161,7 +174,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
// with nothing derived, Auto advances without writing; skipping recording
// leaves no pending saves, so finishing needs no restart
const restarts = await captureRestarts(page);
await expect(page.getByText("No compatible GPU found")).toBeVisible();
await expect(page.getByText("No supported video card found")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("You're All Set!")).toBeVisible();
@@ -181,6 +194,84 @@ test.describe("setup wizard hardware @high @mobile", () => {
});
});
test("offers only the presets the hardware supports", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({
config: { onboarding: { setup_complete: false } } as never,
hardware: NVIDIA_HARDWARE,
hwaccel: {
recommended: "nvidia",
available: [{ key: "nvidia", presets: { any: "preset-nvidia" } }],
},
});
await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page);
await page
.getByRole("button", { name: "Continue without detection" })
.click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
// an NVIDIA box has no business being offered Rockchip or Pi decoding
await expect(
page.getByRole("radio", { name: "CUDA (NVIDIA)" }),
).toBeVisible();
await expect(
page.getByRole("radio", { name: /Raspberry Pi/ }),
).toBeHidden();
await expect(page.getByRole("radio", { name: /Rockchip/ })).toBeHidden();
// Auto and None are always available
await expect(page.getByRole("radio", { name: "Auto" })).toBeVisible();
await expect(
page.getByRole("radio", { name: "None (software decoding)" }),
).toBeVisible();
});
test("a codec specific family falls back to h264 with no cameras", async ({
frigateApp,
page,
}) => {
await frigateApp.installDefaults({
config: { onboarding: { setup_complete: false } } as never,
hwaccel: {
recommended: "jetson",
available: [
{
key: "jetson",
presets: {
h264: "preset-jetson-h264",
h265: "preset-jetson-h265",
},
},
],
},
});
const saves = await captureSaves(page);
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
await gotoDetectorStep(page);
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
// the family is named once, not split into codec variants the user would
// have to choose between
await expect(
page.getByRole("radio", { name: "NVIDIA Jetson" }),
).toBeVisible();
await page.getByRole("button", { name: "Next" }).click();
// no camera was added through the wizard, so there is no codec to match
// and the family's first preset stands in
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
hwaccel_args: "preset-jetson-h264",
});
});
test("None writes an explicit empty hwaccel list", async ({
frigateApp,
page,
+25 -10
View File
@@ -27,16 +27,16 @@
"description": "Speed up video decoding with your GPU.",
"detecting": "Checking your hardware...",
"auto": "Auto",
"autoResolved": "Will use {{preset}} based on your detection hardware.",
"autoNone": "No compatible GPU found. Frigate will decide at startup.",
"autoResolved": "Will use {{family}} for your cameras.",
"autoNone": "No supported video card found. Frigate will decide at startup.",
"recommendFailed": "Hardware detection is unavailable. Frigate will decide at startup.",
"presets": {
"families": {
"nvidia": "CUDA (NVIDIA)",
"vaapi": "VAAPI (Intel/AMD)",
"cuda": "CUDA (NVIDIA)",
"qsv": "QuickSync (Intel)",
"intel-qsv": "QuickSync (Intel)",
"rkmpp": "RKMPP (Rockchip)",
"rpi": "V4L2 (Raspberry Pi)",
"jetson": "NVIDIA Jetson",
"rpi": "V4L2 (Raspberry Pi)",
"none": "None (software decoding)"
}
},
@@ -52,11 +52,26 @@
},
"recording": {
"title": "Recordings",
"description": "Save video from your cameras for later review.",
"description": "Save video from your cameras so you can watch it later.",
"enableRecording": "Enable recordings",
"retentionDays": "Days to retain recordings",
"storageEstimate": "With {{free}} GB free, you can store approximately {{days}} days of recordings for {{cameras}} camera(s).",
"noCameras": "You haven't added cameras yet. Recording will apply when you add cameras in Settings."
"retentionDays": "Keep recordings for (days)",
"storageEstimate": "With {{free}} GB free, {{cameras}} camera(s) recording around the clock fills the disk in roughly {{days}} days.",
"noCameras": "You haven't added cameras yet. Recording will apply when you add cameras in Settings.",
"modeLabel": "What to record",
"modes": {
"events": {
"label": "Only when something is spotted",
"description": "Saves video around people, cars, and other objects Frigate spots. Uses far less disk space."
},
"continuous": {
"label": "All the time",
"description": "Saves video around the clock, so you can go back to any moment. Uses much more disk space."
}
},
"retentionHint": {
"events": "Video of anything Frigate spots is kept this long, then deleted automatically.",
"continuous": "All video is kept this long, then deleted automatically."
}
},
"complete": {
"title": "You're All Set!",
@@ -74,11 +74,15 @@ const STEPS = [
type CameraWizardDialogProps = {
open: boolean;
onClose: () => void;
// reports each camera the wizard saves, so callers can act on the streams
// that were probed without probing them again
onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void;
};
export default function CameraWizardDialog({
open,
onClose,
onCameraAdded,
}: CameraWizardDialogProps) {
const { t } = useTranslation(["views/settings"]);
const { mutate: updateConfig } = useSWR("config");
@@ -271,6 +275,13 @@ export default function CameraWizardDialog({
.put("config/set", requestBody)
.then((response) => {
if (response.status === 200) {
onCameraAdded?.({
name: finalCameraName,
detectCodec: wizardData.streams?.find((stream) =>
stream.roles.includes("detect"),
)?.testResult?.videoCodec,
});
// Configure go2rtc streams for all streams
if (wizardData.streams && wizardData.streams.length > 0) {
const go2rtcStreams: Record<string, string[]> = {};
@@ -393,7 +404,7 @@ export default function CameraWizardDialog({
setIsLoading(false);
});
},
[updateConfig, t, onClose],
[updateConfig, t, onClose, onCameraAdded],
);
return (
+21 -4
View File
@@ -7,7 +7,10 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { FaCircleCheck } from "react-icons/fa6";
type SetupCameraProps = {
onNext: (cameraNames?: string[]) => void;
onNext: (
cameraNames?: string[],
detectCodecs?: Record<string, string>,
) => void;
onBack: () => void;
};
@@ -15,6 +18,7 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
const { t } = useTranslation(["views/setup"]);
const [showWizard, setShowWizard] = useState(false);
const [addedCameras, setAddedCameras] = useState<string[]>([]);
const [detectCodecs, setDetectCodecs] = useState<Record<string, string>>({});
const { mutate: mutateConfig } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@@ -39,9 +43,18 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
}, 1000);
}, [mutateConfig, addedCameras]);
const handleCameraAdded = useCallback(
({ name, detectCodec }: { name: string; detectCodec?: string }) => {
if (detectCodec) {
setDetectCodecs((previous) => ({ ...previous, [name]: detectCodec }));
}
},
[],
);
const handleNext = useCallback(() => {
onNext(addedCameras.length > 0 ? addedCameras : undefined);
}, [onNext, addedCameras]);
onNext(addedCameras.length > 0 ? addedCameras : undefined, detectCodecs);
}, [onNext, addedCameras, detectCodecs]);
return (
<>
@@ -94,7 +107,11 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
</div>
</div>
</div>
<CameraWizardDialog open={showWizard} onClose={handleClose} />
<CameraWizardDialog
open={showWizard}
onClose={handleClose}
onCameraAdded={handleCameraAdded}
/>
</>
);
}
+116 -33
View File
@@ -1,9 +1,9 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { HwaccelRecommendation } from "@/types/hardware";
import type { HwaccelFamily, HwaccelRecommendation } from "@/types/hardware";
import axios from "axios";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import useSWR from "swr";
@@ -11,17 +11,22 @@ import useSWR from "swr";
const AUTO = "auto";
const NONE = "none";
const HWACCEL_PRESETS = [
{ value: "preset-vaapi", key: "vaapi" },
{ value: "preset-nvidia", key: "cuda" },
{ value: "preset-intel-qsv-h264", key: "qsv" },
{ value: "preset-rkmpp", key: "rkmpp" },
{ value: "preset-rpi-64-h264", key: "rpi" },
{ value: "preset-jetson-h264", key: "jetson" },
] as const;
// the codec key of a preset that decodes anything
const ANY_CODEC = "any";
// ffprobe names h265 streams hevc
const CODEC_ALIASES: Record<string, string> = { hevc: "h265" };
function normalizeCodec(codec: string): string {
const lower = codec.toLowerCase();
return CODEC_ALIASES[lower] ?? lower;
}
type SetupHwAccelProps = {
detectorHardwareKey?: string;
// detect stream codec of each camera added in the wizard, keyed by camera
// name. hwaccel only applies to the detect stream.
detectCodecs: Record<string, string>;
// saved reports whether a config write happened, so the wizard knows
// whether finishing requires a restart
onNext: (saved: boolean) => void;
@@ -31,47 +36,123 @@ type SetupHwAccelProps = {
export default function SetupHwAccel({
detectorHardwareKey,
detectCodecs,
onNext,
onBack,
onSkip,
}: SetupHwAccelProps) {
const { t } = useTranslation(["views/setup"]);
const cameraCodecs = useMemo(
() =>
Object.entries(detectCodecs).map(([camera, codec]) => ({
camera,
codec: normalizeCodec(codec),
})),
[detectCodecs],
);
const query = useMemo(() => {
const params = new URLSearchParams();
if (detectorHardwareKey) {
params.set("detector", detectorHardwareKey);
}
const codecs = [...new Set(cameraCodecs.map((entry) => entry.codec))];
if (codecs.length > 0) {
params.set("codecs", codecs.join(","));
}
return params.toString();
}, [detectorHardwareKey, cameraCodecs]);
const {
data: recommendation,
isLoading,
error: recommendError,
} = useSWR<HwaccelRecommendation>(
detectorHardwareKey
? `hardware/hwaccel?detector=${encodeURIComponent(detectorHardwareKey)}`
: "hardware/hwaccel",
query ? `hardware/hwaccel?${query}` : "hardware/hwaccel",
{ revalidateOnFocus: false },
);
const [selected, setSelected] = useState<string>(AUTO);
const [saving, setSaving] = useState(false);
const derived = recommendation?.preset ?? "";
const families = useMemo(
() => recommendation?.available ?? [],
[recommendation],
);
const derived = recommendation?.recommended ?? "";
/** The config a family should be saved as, or null when it writes nothing. */
const configFor = useCallback(
(family: HwaccelFamily | undefined): Record<string, unknown> | null => {
if (!family) {
return null;
}
const shared = family.presets[ANY_CODEC];
if (shared) {
return { ffmpeg: { hwaccel_args: shared } };
}
// this family decodes one codec per preset, so each camera needs the
// preset matching its own detect stream
const perCamera = cameraCodecs
.map((entry) => ({ ...entry, preset: family.presets[entry.codec] }))
.filter((entry) => entry.preset);
if (perCamera.length === 0) {
// no camera to match, so fall back to the family's first preset
const fallback = Object.values(family.presets)[0];
return fallback ? { ffmpeg: { hwaccel_args: fallback } } : null;
}
const presets = new Set(perCamera.map((entry) => entry.preset));
if (presets.size === 1 && perCamera.length === cameraCodecs.length) {
// every camera wants the same preset, so one global value says it
return { ffmpeg: { hwaccel_args: [...presets][0] } };
}
// the global stays on auto, so cameras added later still get resolved
// at startup rather than inheriting one camera's codec
return {
cameras: Object.fromEntries(
perCamera.map((entry) => [
entry.camera,
{ ffmpeg: { hwaccel_args: entry.preset } },
]),
),
};
},
[cameraCodecs],
);
const handleSave = useCallback(async () => {
const key = selected === AUTO ? derived : selected;
const configData =
selected === NONE
? // an empty string would make config/set delete the key (reviving the
// "auto" default), so an explicit no-hwaccel is an empty list
{ ffmpeg: { hwaccel_args: [] } }
: configFor(families.find((family) => family.key === key));
// Auto with nothing derived writes nothing: the config default of "auto"
// stays in place and the backend decides at startup
if (selected === AUTO && !derived) {
if (!configData) {
onNext(false);
return;
}
// an empty string would make config/set delete the key (reviving the
// "auto" default), so an explicit no-hwaccel is an empty list
const preset: string | string[] =
selected === AUTO ? derived : selected === NONE ? [] : selected;
setSaving(true);
try {
await axios.put("config/set", {
config_data: {
ffmpeg: { hwaccel_args: preset },
},
config_data: configData,
requires_restart: 1,
});
onNext(true);
@@ -80,7 +161,7 @@ export default function SetupHwAccel({
} finally {
setSaving(false);
}
}, [selected, derived, onNext, t]);
}, [selected, derived, families, configFor, onNext, t]);
if (isLoading) {
return (
@@ -123,25 +204,27 @@ export default function SetupHwAccel({
</div>
<p className="ml-6 text-xs text-muted-foreground">
{derived
? t("setupWizard.hwaccel.autoResolved", { preset: derived })
? t("setupWizard.hwaccel.autoResolved", {
family: t(`setupWizard.hwaccel.families.${derived}`),
})
: recommendError
? t("setupWizard.hwaccel.recommendFailed")
: t("setupWizard.hwaccel.autoNone")}
</p>
</div>
{HWACCEL_PRESETS.map((preset) => (
<div key={preset.key} className="flex items-center space-x-2">
{families.map((family) => (
<div key={family.key} className="flex items-center space-x-2">
<RadioGroupItem
value={preset.value}
id={`hwaccel-${preset.key}`}
className={radioClass(preset.value)}
value={family.key}
id={`hwaccel-${family.key}`}
className={radioClass(family.key)}
/>
<label
htmlFor={`hwaccel-${preset.key}`}
htmlFor={`hwaccel-${family.key}`}
className="cursor-pointer text-sm"
>
{t(`setupWizard.hwaccel.presets.${preset.key}`)}
{t(`setupWizard.hwaccel.families.${family.key}`)}
</label>
</div>
))}
@@ -153,7 +236,7 @@ export default function SetupHwAccel({
className={radioClass(NONE)}
/>
<label htmlFor="hwaccel-none" className="cursor-pointer text-sm">
{t("setupWizard.hwaccel.presets.none")}
{t("setupWizard.hwaccel.families.none")}
</label>
</div>
</RadioGroup>
+67 -17
View File
@@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -8,6 +9,11 @@ import { toast } from "sonner";
import axios from "axios";
import useSWR from "swr";
const EVENTS = "events";
const CONTINUOUS = "continuous";
const MODES = [EVENTS, CONTINUOUS] as const;
type SetupRecordingProps = {
cameraNames: string[];
onNext: () => void;
@@ -23,6 +29,7 @@ export default function SetupRecording({
}: SetupRecordingProps) {
const { t } = useTranslation(["views/setup"]);
const [enabled, setEnabled] = useState(true);
const [mode, setMode] = useState<string>(EVENTS);
const [retentionDays, setRetentionDays] = useState(10);
const [saving, setSaving] = useState(false);
@@ -41,14 +48,18 @@ export default function SetupRecording({
const handleSave = useCallback(async () => {
setSaving(true);
try {
const record: Record<string, unknown> = { enabled };
if (enabled) {
record.alerts = { retain: { days: retentionDays } };
record.detections = { retain: { days: retentionDays } };
// continuous keeps every segment, so it is the one that needs turning
// on. Its default of 0 already means "only what was detected".
record.continuous = { days: mode === CONTINUOUS ? retentionDays : 0 };
}
await axios.put("config/set", {
config_data: {
record: {
enabled,
alerts: { retain: { days: retentionDays } },
detections: { retain: { days: retentionDays } },
},
},
config_data: { record },
requires_restart: 1,
});
onNext();
@@ -57,7 +68,7 @@ export default function SetupRecording({
} finally {
setSaving(false);
}
}, [enabled, retentionDays, onNext, t]);
}, [enabled, mode, retentionDays, onNext, t]);
return (
<div className="flex flex-col gap-4 py-4">
@@ -89,6 +100,36 @@ export default function SetupRecording({
{enabled && (
<>
<div className="flex flex-col gap-2">
<Label>{t("setupWizard.recording.modeLabel")}</Label>
<RadioGroup value={mode} onValueChange={setMode}>
{MODES.map((option) => (
<div key={option} className="flex flex-col gap-0.5">
<div className="flex items-center space-x-2">
<RadioGroupItem
value={option}
id={`recording-mode-${option}`}
className={
mode === option
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<label
htmlFor={`recording-mode-${option}`}
className="cursor-pointer text-sm font-medium"
>
{t(`setupWizard.recording.modes.${option}.label`)}
</label>
</div>
<p className="ml-6 text-xs text-muted-foreground">
{t(`setupWizard.recording.modes.${option}.description`)}
</p>
</div>
))}
</RadioGroup>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="retention-days">
{t("setupWizard.recording.retentionDays")}
@@ -99,21 +140,30 @@ export default function SetupRecording({
min={1}
max={365}
value={retentionDays}
// the spinner arrows are noise at this size, and the field is
// still typeable and arrow-key steppable without them
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
onChange={(e) =>
setRetentionDays(Math.max(1, parseInt(e.target.value) || 1))
}
/>
<p className="text-xs text-muted-foreground">
{t(`setupWizard.recording.retentionHint.${mode}`)}
</p>
</div>
{freeGb !== null && estimatedDays !== null && cameraCount > 0 && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.recording.storageEstimate", {
free: freeGb,
days: estimatedDays,
cameras: cameraCount,
})}
</p>
)}
{mode === CONTINUOUS &&
freeGb !== null &&
estimatedDays !== null &&
cameraCount > 0 && (
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
{t("setupWizard.recording.storageEstimate", {
free: freeGb,
days: estimatedDays,
cameras: cameraCount,
})}
</p>
)}
</>
)}
+24 -8
View File
@@ -24,6 +24,8 @@ type WizardState = {
currentStep: number;
cameraNames: string[];
detectorHardwareKey?: string;
// detect stream codec of each added camera, keyed by camera name
detectCodecs: Record<string, string>;
// whether any step wrote config with requires_restart, which only takes
// effect after a restart (camera adds apply live and don't count)
restartRequired: boolean;
@@ -38,7 +40,11 @@ type WizardState = {
type WizardAction =
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "CAMERAS_ADDED"; cameraNames: string[] }
| {
type: "CAMERAS_ADDED";
cameraNames: string[];
detectCodecs: Record<string, string>;
}
| {
type: "STEP_CONFIGURED";
step: keyof WizardState["configuredSteps"];
@@ -50,6 +56,7 @@ type WizardAction =
const initialState: WizardState = {
currentStep: 0,
cameraNames: [],
detectCodecs: {},
restartRequired: false,
configuredSteps: {
camera: false,
@@ -73,6 +80,7 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState {
...state,
currentStep: state.currentStep + 1,
cameraNames: action.cameraNames,
detectCodecs: action.detectCodecs,
configuredSteps: { ...state.configuredSteps, camera: true },
};
case "STEP_CONFIGURED":
@@ -118,13 +126,20 @@ export default function SetupWizard() {
}
}, [t]);
const handleCameraNext = useCallback((cameraNames?: string[]) => {
if (cameraNames && cameraNames.length > 0) {
dispatch({ type: "CAMERAS_ADDED", cameraNames });
} else {
dispatch({ type: "SKIP_STEP" });
}
}, []);
const handleCameraNext = useCallback(
(cameraNames?: string[], detectCodecs?: Record<string, string>) => {
if (cameraNames && cameraNames.length > 0) {
dispatch({
type: "CAMERAS_ADDED",
cameraNames,
detectCodecs: detectCodecs ?? {},
});
} else {
dispatch({ type: "SKIP_STEP" });
}
},
[],
);
const handleHwAccelNext = useCallback((saved: boolean) => {
dispatch({ type: "STEP_CONFIGURED", step: "hwaccel", savedConfig: saved });
@@ -174,6 +189,7 @@ export default function SetupWizard() {
return (
<SetupHwAccel
detectorHardwareKey={state.detectorHardwareKey}
detectCodecs={state.detectCodecs}
onNext={handleHwAccelNext}
onBack={handleBack}
onSkip={handleSkipStep}
+9 -1
View File
@@ -12,6 +12,14 @@ export type DetectionHardware = {
unlimited: boolean;
};
export type HwaccelFamily = {
key: string;
// the preset for each codec this family decodes, or a single "any" preset
// when it decodes every codec
presets: Record<string, string>;
};
export type HwaccelRecommendation = {
preset: string;
recommended: string;
available: HwaccelFamily[];
};