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-22 11:51:08 -05:00
parent bb5e9264b0
commit d02811d2b4
13 changed files with 815 additions and 178 deletions
+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>
)}
</>
)}