This commit is contained in:
Josh Hawkins
2026-08-21 12:39:50 -05:00
parent 2a4d48aa3c
commit ebb6a316c8
11 changed files with 36 additions and 74 deletions
+1 -2
View File
@@ -121,8 +121,7 @@ class TestIntelGeneration(HwaccelRecommendationTestCase):
self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv") self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv")
def test_gen13_prefers_qsv_for_mixed_codecs(self): def test_gen13_prefers_qsv_for_mixed_codecs(self):
# every camera resolves the family to its own codec, so mixed streams # each camera resolves the family to its own codec
# are no longer a reason to drop to vaapi
self.write_cpuinfo("13th Gen Intel(R) Core(TM) i5-13500") self.write_cpuinfo("13th Gen Intel(R) Core(TM) i5-13500")
self.assertEqual( self.assertEqual(
self.recommend(["openvino:GPU"], codecs={"h264", "h265"}), "intel-qsv" self.recommend(["openvino:GPU"], codecs={"h264", "h265"}), "intel-qsv"
+5 -9
View File
@@ -26,7 +26,6 @@ logger = logging.getLogger(__name__)
# root the /proc reads use, so tests can point them at a fixture tree # root the /proc reads use, so tests can point them at a fixture tree
PROC_ROOT = "/proc" PROC_ROOT = "/proc"
# stands in for the codec of a preset that decodes anything
ANY_CODEC = "any" ANY_CODEC = "any"
# a Raspberry Pi has no detection hardware of its own, so it gets a key here # a Raspberry Pi has no detection hardware of its own, so it gets a key here
@@ -35,11 +34,10 @@ RASPBERRY_PI = "raspberrypi"
# ffprobe names h265 streams hevc # ffprobe names h265 streams hevc
CODEC_ALIASES = {"hevc": "h265"} CODEC_ALIASES = {"hevc": "h265"}
# marketing name of a newer Intel CPU, e.g. "13th Gen Intel(R) Core(TM) i5-13500" # e.g. "13th Gen Intel(R) Core(TM) i5-13500"
INTEL_GEN_PATTERN = re.compile(r"(\d+)th Gen") INTEL_GEN_PATTERN = re.compile(r"(\d+)th Gen")
# Core Ultra dropped that prefix and is newer than every numbered generation # Core Ultra dropped the generation prefix and is newer than all of them
INTEL_ULTRA_PATTERN = re.compile(r"Core\(TM\) Ultra") INTEL_ULTRA_PATTERN = re.compile(r"Core\(TM\) Ultra")
# stands in for a generation newer than any numbered one
INTEL_GEN_LATEST = 99 INTEL_GEN_LATEST = 99
# per the hwaccel docs, gen13+ and Arc prefer qsv while older is safest on # per the hwaccel docs, gen13+ and Arc prefer qsv while older is safest on
@@ -47,8 +45,7 @@ INTEL_GEN_LATEST = 99
INTEL_QSV_MIN_GEN = 13 INTEL_QSV_MIN_GEN = 13
INTEL_QSV_SUPPORTED_GEN = 8 INTEL_QSV_SUPPORTED_GEN = 8
# detection hardware whose GPU also decodes video, in recommendation priority # decode capable detection hardware, in recommendation priority order
# order
DECODE_HARDWARE = ( DECODE_HARDWARE = (
"onnx:nvidia", "onnx:nvidia",
"tensorrt", "tensorrt",
@@ -150,7 +147,6 @@ def _intel_families(generation: int | None) -> list[HwaccelFamily]:
if generation is not None and generation >= INTEL_QSV_MIN_GEN: if generation is not None and generation >= INTEL_QSV_MIN_GEN:
return [FAMILY_QSV, FAMILY_VAAPI] return [FAMILY_QSV, FAMILY_VAAPI]
# gen8 to gen12 can do either, and the docs call vaapi the safer default
return [FAMILY_VAAPI, FAMILY_QSV] return [FAMILY_VAAPI, FAMILY_QSV]
@@ -253,8 +249,8 @@ def hwaccel_options(
def _recommend(families: list[HwaccelFamily], codecs_known: bool) -> str: def _recommend(families: list[HwaccelFamily], codecs_known: bool) -> str:
"""Pick the family to default to out of the ones this hardware can use.""" """Pick the family to default to out of the ones this hardware can use."""
if not codecs_known: if not codecs_known:
# nothing says which codec a camera will send, and a codec specific # a codec specific family would have to guess a codec for cameras
# family would have to guess one, so anything that decodes them all wins # that do not exist yet
for family in families: for family in families:
if ANY_CODEC in family.presets: if ANY_CODEC in family.presets:
return family.key return family.key
+9 -20
View File
@@ -57,16 +57,12 @@ async function captureRestarts(page: Page): Promise<string[]> {
} }
async function gotoDetectorStep(page: Page) { async function gotoDetectorStep(page: Page) {
// welcome -> camera -> detector
await page.getByRole("button", { name: "Get Started" }).click(); await page.getByRole("button", { name: "Get Started" }).click();
await expect(page.getByText("Add Your First Camera")).toBeVisible(); await expect(page.getByText("Add Your First Camera")).toBeVisible();
// The camera step's Next button only renders once addedCameras is // the camera step's Next only renders once its local addedCameras fills,
// non-empty, and that state is local to the step (it isn't seeded from // which SetupCamera does by refetching config when the dialog closes, so
// the cameras already in config). Opening and cancelling the add-camera // opening and cancelling the dialog is what reveals Next
// dialog is enough: its close handler force-refetches /api/config, which
// picks up the cameras already present in the mocked config and reveals
// Next -- see SetupCamera.tsx's handleClose.
await page.getByRole("button", { name: "Add Camera" }).click(); await page.getByRole("button", { name: "Add Camera" }).click();
await page.getByRole("button", { name: "Cancel" }).click(); await page.getByRole("button", { name: "Cancel" }).click();
@@ -112,7 +108,6 @@ test.describe("setup wizard hardware @high @mobile", () => {
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
// detector -> hwaccel
await expect(page.getByText("Hardware Acceleration")).toBeVisible(); await expect(page.getByText("Hardware Acceleration")).toBeVisible();
const detectorSave = saves.find((save) => save.config_data?.models); const detectorSave = saves.find((save) => save.config_data?.models);
@@ -121,8 +116,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
]); ]);
expect(detectorSave?.config_data?.detect).toEqual({ enabled: true }); expect(detectorSave?.config_data?.detect).toEqual({ enabled: true });
// Auto names the family derived from the chosen hardware and writes its // VAAPI decodes any codec, so one global value covers every camera
// preset; VAAPI decodes any codec, so one global value covers every camera
await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible(); await expect(page.getByText("Will use VAAPI (Intel/AMD)")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
@@ -131,8 +125,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
hwaccel_args: "preset-vaapi", hwaccel_args: "preset-vaapi",
}); });
// hwaccel -> recording (skipped) -> complete, where the saved steps only // the saved steps only take effect after a restart
// take effect after a restart and the primary action says so
const restarts = await captureRestarts(page); const restarts = await captureRestarts(page);
await page.getByRole("button", { name: "Skip" }).click(); await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("You're All Set!")).toBeVisible(); await expect(page.getByText("You're All Set!")).toBeVisible();
@@ -171,8 +164,7 @@ test.describe("setup wizard hardware @high @mobile", () => {
await expect(page.getByText("Hardware Acceleration")).toBeVisible(); await expect(page.getByText("Hardware Acceleration")).toBeVisible();
expect(saves.filter((save) => save.config_data?.models)).toHaveLength(0); expect(saves.filter((save) => save.config_data?.models)).toHaveLength(0);
// with nothing derived, Auto advances without writing; skipping recording // nothing derived and nothing saved, so finishing needs no restart
// leaves no pending saves, so finishing needs no restart
const restarts = await captureRestarts(page); const restarts = await captureRestarts(page);
await expect(page.getByText("No supported video card found")).toBeVisible(); await expect(page.getByText("No supported video card found")).toBeVisible();
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
@@ -184,8 +176,8 @@ test.describe("setup wizard hardware @high @mobile", () => {
).toBeHidden(); ).toBeHidden();
await page.getByRole("button", { name: "Go to Live View" }).click(); await page.getByRole("button", { name: "Go to Live View" }).click();
// hands off without restarting; the mocked config still reports // the mocked config still reports onboarding incomplete, so the reload
// onboarding incomplete, so the reload lands back on the wizard // lands back on the wizard
await expect(page.getByText("Welcome to Frigate")).toBeVisible(); await expect(page.getByText("Welcome to Frigate")).toBeVisible();
expect(restarts).toHaveLength(0); expect(restarts).toHaveLength(0);
const finishSave = saves.find((save) => save.config_data?.onboarding); const finishSave = saves.find((save) => save.config_data?.onboarding);
@@ -257,15 +249,12 @@ test.describe("setup wizard hardware @high @mobile", () => {
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Hardware Acceleration")).toBeVisible(); 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( await expect(
page.getByRole("radio", { name: "NVIDIA Jetson" }), page.getByRole("radio", { name: "NVIDIA Jetson" }),
).toBeVisible(); ).toBeVisible();
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
// no camera was added through the wizard, so there is no codec to match // no camera was added, so there is no codec to match
// and the family's first preset stands in
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg); const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({ expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
hwaccel_args: "preset-jetson-h264", hwaccel_args: "preset-jetson-h264",
@@ -74,8 +74,7 @@ const STEPS = [
type CameraWizardDialogProps = { type CameraWizardDialogProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
// reports each camera the wizard saves, so callers can act on the streams // lets callers reuse what was probed here instead of probing again
// that were probed without probing them again
onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void; onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void;
}; };
+3 -5
View File
@@ -25,8 +25,8 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setShowWizard(false); setShowWizard(false);
// Delay to let CameraWizardDialog's config save and go2rtc setup complete, // the dialog's config save and go2rtc setup need a moment to land, and
// then force a fresh fetch from the server (bypassing SWR cache) // the SWR cache would serve the pre-save config
setTimeout(() => { setTimeout(() => {
fetch(`${window.baseUrl || ""}api/config`) fetch(`${window.baseUrl || ""}api/config`)
.then((res) => res.json()) .then((res) => res.json())
@@ -37,9 +37,7 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
setAddedCameras(cameraNames); setAddedCameras(cameraNames);
} }
}) })
.catch(() => { .catch(() => {});
// Fetch failed, stay on this step
});
}, 1000); }, 1000);
}, [mutateConfig, addedCameras]); }, [mutateConfig, addedCameras]);
+2 -7
View File
@@ -87,7 +87,6 @@ export default function SetupComplete({
const handleFinish = useCallback(async () => { const handleFinish = useCallback(async () => {
setFinishing(true); setFinishing(true);
try { try {
// Mark setup as complete
await axios.put("config/set", { await axios.put("config/set", {
config_data: { config_data: {
onboarding: { setup_complete: true }, onboarding: { setup_complete: true },
@@ -95,8 +94,7 @@ export default function SetupComplete({
requires_restart: 0, requires_restart: 0,
}); });
// camera adds were applied live, so with no pending requires_restart // camera adds were applied live, so nothing is waiting on a restart
// saves the wizard can hand off without restarting
if (!restartRequired) { if (!restartRequired) {
window.location.href = window.baseUrl || "/"; window.location.href = window.baseUrl || "/";
return; return;
@@ -104,10 +102,8 @@ export default function SetupComplete({
setRestarting(true); setRestarting(true);
// Trigger restart
await axios.post("restart"); await axios.post("restart");
// Poll for server availability with max retries
let retries = 0; let retries = 0;
const maxRetries = 60; // 2 minutes max const maxRetries = 60; // 2 minutes max
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
@@ -116,7 +112,6 @@ export default function SetupComplete({
if (pollRef.current) { if (pollRef.current) {
clearInterval(pollRef.current); clearInterval(pollRef.current);
} }
// Give up polling, redirect anyway
window.location.href = window.baseUrl || "/"; window.location.href = window.baseUrl || "/";
return; return;
} }
@@ -129,7 +124,7 @@ export default function SetupComplete({
window.location.href = window.baseUrl || "/"; window.location.href = window.baseUrl || "/";
} }
} catch { } catch {
// Server not ready yet // not back yet
} }
}, 2000); }, 2000);
} catch { } catch {
+3 -4
View File
@@ -20,8 +20,8 @@ import { LuExternalLink } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
// detectors that ship no default model; configuring them without one leaves // these ship no default model, so configuring one without a model leaves the
// the detector unable to start // detector unable to start
const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"]; const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"];
const CPU_FALLBACK: DetectionHardware[] = [ const CPU_FALLBACK: DetectionHardware[] = [
@@ -64,7 +64,7 @@ export default function SetupDetector({
}); });
const plusEnabled = Boolean(config?.plus?.enabled); const plusEnabled = Boolean(config?.plus?.enabled);
// the cpu is always probed, so an empty or failed probe leaves a fallback // the cpu is always probed, so an empty list means the probe failed
const options = useMemo( const options = useMemo(
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK), () => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
[hardware], [hardware],
@@ -121,7 +121,6 @@ export default function SetupDetector({
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (needsModel && !plusModelId) { if (needsModel && !plusModelId) {
// no model to run on this hardware yet, so leave the config untouched
onSkip(selected.key); onSkip(selected.key);
return; return;
} }
+7 -15
View File
@@ -11,7 +11,6 @@ import useSWR from "swr";
const AUTO = "auto"; const AUTO = "auto";
const NONE = "none"; const NONE = "none";
// the codec key of a preset that decodes anything
const ANY_CODEC = "any"; const ANY_CODEC = "any";
// ffprobe names h265 streams hevc // ffprobe names h265 streams hevc
@@ -24,11 +23,9 @@ function normalizeCodec(codec: string): string {
type SetupHwAccelProps = { type SetupHwAccelProps = {
detectorHardwareKey?: string; detectorHardwareKey?: string;
// detect stream codec of each camera added in the wizard, keyed by camera // camera name -> detect stream codec, the only stream hwaccel applies to
// name. hwaccel only applies to the detect stream.
detectCodecs: Record<string, string>; detectCodecs: Record<string, string>;
// saved reports whether a config write happened, so the wizard knows // saved tells the wizard whether finishing needs a restart
// whether finishing requires a restart
onNext: (saved: boolean) => void; onNext: (saved: boolean) => void;
onBack: () => void; onBack: () => void;
onSkip: () => void; onSkip: () => void;
@@ -99,14 +96,11 @@ export default function SetupHwAccel({
return { ffmpeg: { hwaccel_args: 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 const perCamera = cameraCodecs
.map((entry) => ({ ...entry, preset: family.presets[entry.codec] })) .map((entry) => ({ ...entry, preset: family.presets[entry.codec] }))
.filter((entry) => entry.preset); .filter((entry) => entry.preset);
if (perCamera.length === 0) { if (perCamera.length === 0) {
// no camera to match, so fall back to the family's first preset
const fallback = Object.values(family.presets)[0]; const fallback = Object.values(family.presets)[0];
return fallback ? { ffmpeg: { hwaccel_args: fallback } } : null; return fallback ? { ffmpeg: { hwaccel_args: fallback } } : null;
} }
@@ -114,12 +108,11 @@ export default function SetupHwAccel({
const presets = new Set(perCamera.map((entry) => entry.preset)); const presets = new Set(perCamera.map((entry) => entry.preset));
if (presets.size === 1 && perCamera.length === cameraCodecs.length) { 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] } }; return { ffmpeg: { hwaccel_args: [...presets][0] } };
} }
// the global stays on auto, so cameras added later still get resolved // the global stays on auto so cameras added later resolve at startup
// at startup rather than inheriting one camera's codec // instead of inheriting one camera's codec
return { return {
cameras: Object.fromEntries( cameras: Object.fromEntries(
perCamera.map((entry) => [ perCamera.map((entry) => [
@@ -137,13 +130,12 @@ export default function SetupHwAccel({
const configData = const configData =
selected === NONE selected === NONE
? // an empty string would make config/set delete the key (reviving the ? // an empty string would make config/set delete the key, reviving
// "auto" default), so an explicit no-hwaccel is an empty list // the "auto" default
{ ffmpeg: { hwaccel_args: [] } } { ffmpeg: { hwaccel_args: [] } }
: configFor(families.find((family) => family.key === key)); : configFor(families.find((family) => family.key === key));
// Auto with nothing derived writes nothing: the config default of "auto" // nothing to write leaves the config default of "auto" in place
// stays in place and the backend decides at startup
if (!configData) { if (!configData) {
onNext(false); onNext(false);
return; return;
+2 -5
View File
@@ -35,7 +35,6 @@ export default function SetupRecording({
const { data: stats } = useSWR("stats", { revalidateOnFocus: false }); const { data: stats } = useSWR("stats", { revalidateOnFocus: false });
// Calculate storage estimate
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"]; const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null; const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
const cameraCount = cameraNames.length; const cameraCount = cameraNames.length;
@@ -53,8 +52,7 @@ export default function SetupRecording({
if (enabled) { if (enabled) {
record.alerts = { retain: { days: retentionDays } }; record.alerts = { retain: { days: retentionDays } };
record.detections = { retain: { days: retentionDays } }; record.detections = { retain: { days: retentionDays } };
// continuous keeps every segment, so it is the one that needs turning // written even when off, so switching modes back turns it off again
// on. Its default of 0 already means "only what was detected".
record.continuous = { days: mode === CONTINUOUS ? retentionDays : 0 }; record.continuous = { days: mode === CONTINUOUS ? retentionDays : 0 };
} }
@@ -140,8 +138,7 @@ export default function SetupRecording({
min={1} min={1}
max={365} max={365}
value={retentionDays} value={retentionDays}
// the spinner arrows are noise at this size, and the field is // drop the spinner arrows; typing and arrow keys still work
// still typeable and arrow-key steppable without them
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
onChange={(e) => onChange={(e) =>
setRetentionDays(Math.max(1, parseInt(e.target.value) || 1)) setRetentionDays(Math.max(1, parseInt(e.target.value) || 1))
+2 -3
View File
@@ -24,10 +24,9 @@ type WizardState = {
currentStep: number; currentStep: number;
cameraNames: string[]; cameraNames: string[];
detectorHardwareKey?: string; detectorHardwareKey?: string;
// detect stream codec of each added camera, keyed by camera name // camera name -> detect stream codec
detectCodecs: Record<string, string>; detectCodecs: Record<string, string>;
// whether any step wrote config with requires_restart, which only takes // camera adds apply live, so they don't count toward needing a restart
// effect after a restart (camera adds apply live and don't count)
restartRequired: boolean; restartRequired: boolean;
configuredSteps: { configuredSteps: {
camera: boolean; camera: boolean;
+1 -2
View File
@@ -14,8 +14,7 @@ export type DetectionHardware = {
export type HwaccelFamily = { export type HwaccelFamily = {
key: string; key: string;
// the preset for each codec this family decodes, or a single "any" preset // keyed by codec, or a single "any" preset when it decodes every codec
// when it decodes every codec
presets: Record<string, string>; presets: Record<string, string>;
}; };