diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 64f0a41580..cf76640baa 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -4053,6 +4053,58 @@ paths: security: - frigateAdminAuth: [] x-required-role: admin + /hardware/hwaccel: + get: + tags: + - Hardware + summary: Hwaccel Recommendation + description: |- + **Access:** Admin role required. + + Get the hardware decoding this system can do. + + Args: + detector: Hardware key of the detection hardware in use, which biases + the recommendation toward that hardware's GPU + codecs: Comma separated codecs of the streams that will be decoded, + used to drop families that cannot decode one of them + + Returns: + The recommended family (empty when none fits) and every usable family + operationId: hwaccel_recommendation_hardware_hwaccel_get + parameters: + - name: detector + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Detector + - name: codecs + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Codecs + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/HwaccelRecommendation' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin /events: get: tags: @@ -8670,6 +8722,43 @@ components: - label title: HardwareUnit description: One physical piece of hardware. + HwaccelFamily: + properties: + key: + type: string + title: Family key + description: Stable identifier for this kind of hardware decoding. + presets: + additionalProperties: + type: string + type: object + title: Presets + description: The ffmpeg preset for each codec this family decodes, or + a single 'any' preset when it decodes every codec. + type: object + required: + - key + - presets + title: HwaccelFamily + description: A kind of hardware decoding, and the presets that drive it. + HwaccelRecommendation: + properties: + recommended: + type: string + title: Recommended family + description: Key of the family that fits this system best, or an empty + string when none does. + available: + items: + $ref: '#/components/schemas/HwaccelFamily' + type: array + title: Available families + description: Every family this system's hardware can use, best first. + type: object + required: + - recommended + title: HwaccelRecommendation + description: The hardware decoding this system can do. Last24HoursReview: properties: reviewed_alert: diff --git a/frigate/api/hardware.py b/frigate/api/hardware.py index 43e2157881..37244e8306 100644 --- a/frigate/api/hardware.py +++ b/frigate/api/hardware.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.detectors.hardware import DetectionHardware, hardware_prober -from frigate.util.hwaccel import HwaccelRecommendation, recommend_hwaccel +from frigate.util.hwaccel import HwaccelRecommendation, hwaccel_options logger = logging.getLogger(__name__) @@ -36,14 +36,22 @@ def probe_hardware(refresh: bool = False) -> list[DetectionHardware]: response_model=HwaccelRecommendation, dependencies=[Depends(require_role(["admin"]))], ) -def hwaccel_recommendation(detector: str | None = None) -> HwaccelRecommendation: - """Get the ffmpeg hwaccel preset recommended for this system. +def hwaccel_recommendation( + detector: str | None = None, codecs: str | None = None +) -> HwaccelRecommendation: + """Get the hardware decoding this system can do. Args: detector: Hardware key of the detection hardware in use, which biases the recommendation toward that hardware's GPU + codecs: Comma separated codecs of the streams that will be decoded, + used to drop families that cannot decode one of them Returns: - The recommended preset, empty when no hardware acceleration fits + The recommended family (empty when none fits) and every usable family """ - return HwaccelRecommendation(preset=recommend_hwaccel(detector)) + wanted = { + codec.strip().lower() for codec in (codecs or "").split(",") if codec.strip() + } + recommended, available = hwaccel_options(detector, wanted) + return HwaccelRecommendation(recommended=recommended, available=available) diff --git a/frigate/test/test_hwaccel_recommendation.py b/frigate/test/test_hwaccel_recommendation.py index ce69f2896d..8cd9dae44d 100644 --- a/frigate/test/test_hwaccel_recommendation.py +++ b/frigate/test/test_hwaccel_recommendation.py @@ -1,4 +1,4 @@ -"""Tests for the ffmpeg hwaccel preset recommendation.""" +"""Tests for the hardware decoding recommendation.""" import os import tempfile @@ -38,102 +38,188 @@ class HwaccelRecommendationTestCase(unittest.TestCase): self.drm = drm.start() self.addCleanup(drm.stop) - def recommend(self, keys=(), detector_key=None) -> str: - """Recommend with the prober reporting exactly these hardware keys.""" + def options(self, keys=(), detector_key=None, codecs=None): + """Run the recommendation against a fixed set of hardware keys.""" with patch.object( hwaccel.hardware_prober, "probe", return_value=[found(key) for key in keys], ): - return hwaccel.recommend_hwaccel(detector_key) + return hwaccel.hwaccel_options(detector_key, codecs) + + def recommend(self, keys=(), detector_key=None, codecs=None) -> str: + """The recommended family key.""" + return self.options(keys, detector_key, codecs)[0] + + def available(self, keys=(), detector_key=None, codecs=None) -> list[str]: + """The keys of the usable families, best first.""" + return [family.key for family in self.options(keys, detector_key, codecs)[1]] + + def presets(self, keys=(), detector_key=None, codecs=None) -> dict: + """The presets each usable family provides.""" + return { + family.key: family.presets + for family in self.options(keys, detector_key, codecs)[1] + } def write_cpuinfo(self, model_name: str) -> None: with open(os.path.join(self.proc_root, "cpuinfo"), "w") as f: f.write(f"processor\t: 0\nmodel name\t: {model_name}\n") + def write_device_tree(self) -> None: + os.makedirs(os.path.join(self.proc_root, "device-tree"), exist_ok=True) + with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f: + f.write("raspberrypi,5-model-b\x00brcm,bcm2712\x00") + class TestPriority(HwaccelRecommendationTestCase): def test_nothing_found_recommends_nothing(self): self.assertEqual(self.recommend(), "") def test_nvidia_wins_over_intel(self): - self.assertEqual( - self.recommend(["onnx:nvidia", "openvino:GPU"]), "preset-nvidia" - ) + self.assertEqual(self.recommend(["onnx:nvidia", "openvino:GPU"]), "nvidia") - def test_a_jetson_uses_its_own_preset(self): - self.assertEqual(self.recommend(["tensorrt"]), "preset-jetson-h264") + def test_a_jetson_uses_its_own_family(self): + self.assertEqual(self.recommend(["tensorrt"]), "jetson") def test_a_rockchip_uses_rkmpp(self): - self.assertEqual(self.recommend(["rknn"]), "preset-rkmpp") + self.assertEqual(self.recommend(["rknn"]), "rkmpp") def test_an_amd_gpu_uses_vaapi(self): - self.assertEqual(self.recommend(["onnx:amd"]), "preset-vaapi") + self.assertEqual(self.recommend(["onnx:amd"]), "vaapi") class TestDetectorBias(HwaccelRecommendationTestCase): def test_a_chosen_intel_gpu_beats_a_present_nvidia(self): self.assertEqual( - self.recommend(["onnx:nvidia", "openvino:GPU"], "openvino:GPU"), - "preset-vaapi", + self.recommend(["onnx:nvidia", "openvino:GPU"], "openvino:GPU"), "vaapi" ) def test_a_chosen_npu_decodes_through_the_igpu(self): self.assertEqual( - self.recommend(["openvino:NPU", "openvino:GPU"], "openvino:NPU"), - "preset-vaapi", + self.recommend(["openvino:NPU", "openvino:GPU"], "openvino:NPU"), "vaapi" ) def test_an_npu_without_an_igpu_falls_through(self): self.assertEqual(self.recommend(["openvino:NPU"], "openvino:NPU"), "") def test_a_cpu_choice_still_recommends_the_present_gpu(self): - self.assertEqual(self.recommend(["cpu", "openvino:GPU"], "cpu"), "preset-vaapi") - - def test_a_detector_key_for_absent_hardware_is_ignored(self): - self.assertEqual( - self.recommend(["openvino:GPU"], "onnx:nvidia"), "preset-vaapi" - ) + self.assertEqual(self.recommend(["cpu", "openvino:GPU"], "cpu"), "vaapi") class TestIntelGeneration(HwaccelRecommendationTestCase): - def test_the_xe_driver_uses_qsv(self): + def test_the_xe_driver_prefers_qsv(self): self.drm.return_value = {"0000:00:02.0": "xe"} - self.assertEqual(self.recommend(["openvino:GPU"]), "preset-intel-qsv-h264") + self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv") - def test_gen13_uses_qsv(self): + def test_gen13_prefers_qsv(self): self.write_cpuinfo("13th Gen Intel(R) Core(TM) i5-13500") - self.assertEqual(self.recommend(["openvino:GPU"]), "preset-intel-qsv-h264") + self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv") - def test_gen12_uses_vaapi(self): - self.write_cpuinfo("12th Gen Intel(R) Core(TM) i5-12400") - self.assertEqual(self.recommend(["openvino:GPU"]), "preset-vaapi") - - def test_an_older_model_string_uses_vaapi(self): - self.write_cpuinfo("Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz") - self.assertEqual(self.recommend(["openvino:GPU"]), "preset-vaapi") - - def test_missing_cpuinfo_uses_vaapi(self): - self.assertEqual(self.recommend(["openvino:GPU"]), "preset-vaapi") - - def test_a_core_ultra_uses_qsv(self): + def test_a_core_ultra_prefers_qsv(self): self.write_cpuinfo("Intel(R) Core(TM) Ultra 7 155H") - self.assertEqual(self.recommend(["openvino:GPU"]), "preset-intel-qsv-h264") + self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv") + + def test_gen13_prefers_qsv_for_mixed_codecs(self): + # every camera resolves the family to its own codec, so mixed streams + # are no longer a reason to drop to vaapi + self.write_cpuinfo("13th Gen Intel(R) Core(TM) i5-13500") + self.assertEqual( + self.recommend(["openvino:GPU"], codecs={"h264", "h265"}), "intel-qsv" + ) + + def test_gen12_prefers_vaapi(self): + self.write_cpuinfo("12th Gen Intel(R) Core(TM) i5-12400") + self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "vaapi") + + def test_gen12_still_offers_qsv(self): + self.write_cpuinfo("12th Gen Intel(R) Core(TM) i5-12400") + self.assertEqual(self.available(["openvino:GPU"]), ["vaapi", "intel-qsv"]) + + def test_an_older_model_string_prefers_vaapi(self): + self.write_cpuinfo("Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz") + self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "vaapi") + + def test_missing_cpuinfo_prefers_vaapi(self): + self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "vaapi") + + def test_qsv_is_not_offered_before_gen8(self): + self.write_cpuinfo("7th Gen Intel(R) Core(TM) i5-7500") + self.assertEqual(self.available(["openvino:GPU"]), ["vaapi"]) -class TestRaspberryPi(HwaccelRecommendationTestCase): - def write_device_tree(self) -> None: - os.makedirs(os.path.join(self.proc_root, "device-tree")) - with open(os.path.join(self.proc_root, "device-tree", "compatible"), "w") as f: - f.write("raspberrypi,5-model-b\x00brcm,bcm2712\x00") +class TestUnknownCodecs(HwaccelRecommendationTestCase): + def test_a_codec_agnostic_family_wins_when_no_codec_is_known(self): + # a qsv preset would have to guess a codec for cameras added later + self.drm.return_value = {"0000:00:02.0": "xe"} + self.assertEqual(self.recommend(["openvino:GPU"]), "vaapi") - def test_a_pi_uses_v4l2(self): + def test_hardware_with_no_agnostic_family_still_recommends(self): + self.assertEqual(self.recommend(["tensorrt"]), "jetson") + + +class TestAvailableFamilies(HwaccelRecommendationTestCase): + def test_nothing_found_offers_nothing(self): + self.assertEqual(self.available(), []) + + def test_only_families_the_hardware_can_use_are_offered(self): + self.assertEqual(self.available(["onnx:nvidia"]), ["nvidia"]) + + def test_a_pi_does_not_offer_desktop_gpu_families(self): self.write_device_tree() - self.assertEqual(self.recommend(), "preset-rpi-64-h264") + self.assertEqual(self.available(), ["rpi"]) + + def test_an_intel_system_does_not_offer_the_pi_family(self): + offered = self.available(["openvino:GPU"]) + + self.assertIn("vaapi", offered) + self.assertNotIn("rpi", offered) + self.assertNotIn("nvidia", offered) + + def test_every_gpu_present_is_offered(self): + offered = self.available(["onnx:nvidia", "openvino:GPU"]) + + self.assertEqual(offered[0], "nvidia") + self.assertIn("vaapi", offered) def test_a_gpu_wins_over_the_pi_fallback(self): self.write_device_tree() - self.assertEqual(self.recommend(["onnx:nvidia"]), "preset-nvidia") + self.assertEqual(self.recommend(["onnx:nvidia"]), "nvidia") + + def test_the_recommendation_is_always_offered(self): + recommended, families = self.options(["openvino:GPU"], codecs={"h264"}) + + self.assertIn(recommended, [family.key for family in families]) + + +class TestCodecCoverage(HwaccelRecommendationTestCase): + def test_a_family_carries_a_preset_per_codec(self): + self.assertEqual( + self.presets(["tensorrt"])["jetson"], + {"h264": "preset-jetson-h264", "h265": "preset-jetson-h265"}, + ) + + def test_a_codec_agnostic_family_carries_one_preset(self): + self.assertEqual( + self.presets(["onnx:nvidia"])["nvidia"], {"any": "preset-nvidia"} + ) + + def test_hevc_is_treated_as_h265(self): + self.assertEqual(self.available(["tensorrt"], codecs={"hevc"}), ["jetson"]) + + def test_a_family_that_cannot_decode_a_codec_is_dropped(self): + # a jetson decodes h264 and h265 only, so an mjpeg camera rules it out + self.assertEqual(self.available(["tensorrt"], codecs={"mjpeg"}), []) + + def test_codec_agnostic_families_survive_any_codec(self): + self.assertEqual( + self.available(["onnx:nvidia"], codecs={"mjpeg", "h265"}), ["nvidia"] + ) + + def test_a_dropped_family_hands_off_to_the_next_hardware(self): + self.assertEqual( + self.recommend(["tensorrt", "onnx:nvidia"], codecs={"mjpeg"}), "nvidia" + ) if __name__ == "__main__": diff --git a/frigate/util/hwaccel.py b/frigate/util/hwaccel.py index 718c7a91fd..cdfbc8d08d 100644 --- a/frigate/util/hwaccel.py +++ b/frigate/util/hwaccel.py @@ -1,7 +1,11 @@ -"""Recommendation of an ffmpeg hwaccel preset from the hardware on the system. +"""Recommendation of ffmpeg hwaccel presets from the hardware on the system. Every check is a filesystem read, like the detection hardware probes, so this is cheap enough to serve from the API process. + +Presets are grouped into families because some of them only decode the codec +they name. A family hides that: callers pick the family their hardware needs +and resolve it per camera against that camera's detect stream. """ import logging @@ -22,34 +26,81 @@ logger = logging.getLogger(__name__) # root the /proc reads use, so tests can point them at a fixture tree PROC_ROOT = "/proc" -PRESET_JETSON = "preset-jetson-h264" -PRESET_INTEL_QSV = "preset-intel-qsv-h264" -PRESET_RPI = "preset-rpi-64-h264" +# stands in for the codec of a preset that decodes anything +ANY_CODEC = "any" + +# a Raspberry Pi has no detection hardware of its own, so it gets a key here +RASPBERRY_PI = "raspberrypi" + +# ffprobe names h265 streams hevc +CODEC_ALIASES = {"hevc": "h265"} # marketing name of a newer Intel CPU, e.g. "13th Gen Intel(R) Core(TM) i5-13500" INTEL_GEN_PATTERN = re.compile(r"(\d+)th Gen") +# Core Ultra dropped that prefix and is newer than every numbered generation +INTEL_ULTRA_PATTERN = re.compile(r"Core\(TM\) Ultra") +# stands in for a generation newer than any numbered one +INTEL_GEN_LATEST = 99 -# per the hwaccel docs, gen13+ and Arc use qsv while older is safest on vaapi +# per the hwaccel docs, gen13+ and Arc prefer qsv while older is safest on +# vaapi, and qsv is not supported at all before gen8 INTEL_QSV_MIN_GEN = 13 +INTEL_QSV_SUPPORTED_GEN = 8 # detection hardware whose GPU also decodes video, in recommendation priority -# order. None marks Intel, whose preset depends on the platform generation. -DECODE_PRESETS: dict[str, str | None] = { - "onnx:nvidia": FFMPEG_HWACCEL_NVIDIA, - "tensorrt": PRESET_JETSON, - "rknn": FFMPEG_HWACCEL_RKMPP, - "openvino:GPU": None, - "onnx:amd": FFMPEG_HWACCEL_VAAPI, -} +# order +DECODE_HARDWARE = ( + "onnx:nvidia", + "tensorrt", + "rknn", + "openvino:GPU", + "onnx:amd", + RASPBERRY_PI, +) + + +class HwaccelFamily(BaseModel): + """A kind of hardware decoding, and the presets that drive it.""" + + key: str = Field( + title="Family key", + description="Stable identifier for this kind of hardware decoding.", + ) + presets: dict[str, str] = Field( + title="Presets", + description="The ffmpeg preset for each codec this family decodes, or a single 'any' preset when it decodes every codec.", + ) class HwaccelRecommendation(BaseModel): - """The hwaccel preset recommended for this system.""" + """The hardware decoding this system can do.""" - preset: str = Field( - title="Recommended preset", - description="The ffmpeg hwaccel preset that fits this system's hardware, or an empty string when none does.", + recommended: str = Field( + title="Recommended family", + description="Key of the family that fits this system best, or an empty string when none does.", ) + available: list[HwaccelFamily] = Field( + default_factory=list, + title="Available families", + description="Every family this system's hardware can use, best first.", + ) + + +FAMILY_NVIDIA = HwaccelFamily(key="nvidia", presets={ANY_CODEC: FFMPEG_HWACCEL_NVIDIA}) +FAMILY_VAAPI = HwaccelFamily(key="vaapi", presets={ANY_CODEC: FFMPEG_HWACCEL_VAAPI}) +FAMILY_RKMPP = HwaccelFamily(key="rkmpp", presets={ANY_CODEC: FFMPEG_HWACCEL_RKMPP}) +FAMILY_QSV = HwaccelFamily( + key="intel-qsv", + presets={"h264": "preset-intel-qsv-h264", "h265": "preset-intel-qsv-h265"}, +) +FAMILY_JETSON = HwaccelFamily( + key="jetson", + presets={"h264": "preset-jetson-h264", "h265": "preset-jetson-h265"}, +) +FAMILY_RPI = HwaccelFamily( + key="rpi", + presets={"h264": "preset-rpi-64-h264", "h265": "preset-rpi-64-h265"}, +) def _read(path: str) -> str | None: @@ -61,29 +112,29 @@ def _read(path: str) -> str | None: return None -def _intel_preset() -> str: - """Pick vaapi or qsv for an Intel GPU based on the platform generation.""" +def _intel_generation() -> int | None: + """The Intel platform generation, or None when it cannot be determined.""" # the xe driver only binds to the newest platforms (Arc and later iGPUs) if "xe" in enumerate_drm_devices().values(): - return PRESET_INTEL_QSV + return INTEL_GEN_LATEST cpuinfo = _read(f"{PROC_ROOT}/cpuinfo") or "" for line in cpuinfo.splitlines(): - if line.startswith("model name"): - match = INTEL_GEN_PATTERN.search(line) + if not line.startswith("model name"): + continue - if match and int(match.group(1)) >= INTEL_QSV_MIN_GEN: - return PRESET_INTEL_QSV + match = INTEL_GEN_PATTERN.search(line) - # Core Ultra (Meteor Lake and later) dropped the "Nth Gen" prefix - # but sits past the qsv cutoff - if "Core(TM) Ultra" in line: - return PRESET_INTEL_QSV + if match: + return int(match.group(1)) - break + if INTEL_ULTRA_PATTERN.search(line): + return INTEL_GEN_LATEST - return FFMPEG_HWACCEL_VAAPI + break + + return None def _is_raspberry_pi() -> bool: @@ -91,33 +142,136 @@ def _is_raspberry_pi() -> bool: return "raspberrypi" in compatible -def recommend_hwaccel(detector_key: str | None = None) -> str: - """Recommend an ffmpeg hwaccel preset for this system. +def _intel_families(generation: int | None) -> list[HwaccelFamily]: + """vaapi drives every Intel GPU, qsv only those from gen8 on.""" + if generation is not None and generation < INTEL_QSV_SUPPORTED_GEN: + return [FAMILY_VAAPI] + + if generation is not None and generation >= INTEL_QSV_MIN_GEN: + return [FAMILY_QSV, FAMILY_VAAPI] + + # gen8 to gen12 can do either, and the docs call vaapi the safer default + return [FAMILY_VAAPI, FAMILY_QSV] + + +def _families(key: str, generation: int | None) -> list[HwaccelFamily]: + """Every family that can decode on this hardware, best first.""" + if key == "onnx:nvidia": + return [FAMILY_NVIDIA] + + if key == "tensorrt": + return [FAMILY_JETSON] + + if key == "rknn": + return [FAMILY_RKMPP] + + if key == "onnx:amd": + return [FAMILY_VAAPI] + + if key == RASPBERRY_PI: + return [FAMILY_RPI] + + if key == "openvino:GPU": + return _intel_families(generation) + + return [] + + +def _decodes(family: HwaccelFamily, codecs: set[str]) -> bool: + """Whether a family can decode every codec that is in use.""" + if ANY_CODEC in family.presets: + return True + + return all(codec in family.presets for codec in codecs) + + +def _decode_hardware(detector_key: str | None) -> list[str]: + """Decode capable hardware on this system, best first. + + Args: + detector_key: Hardware key of the detection hardware in use, whose GPU + is preferred over any other + + Returns: + The hardware keys that can decode video, in recommendation order + """ + present = {found.key for found in hardware_prober.probe()} + + if _is_raspberry_pi(): + present.add(RASPBERRY_PI) + + # an Intel NPU decodes through the iGPU next to it + if detector_key == "openvino:NPU": + detector_key = "openvino:GPU" + + ordered = [key for key in DECODE_HARDWARE if key in present] + + if detector_key in ordered: + ordered.remove(detector_key) + ordered.insert(0, detector_key) + + return ordered + + +def hwaccel_options( + detector_key: str | None = None, codecs: set[str] | None = None +) -> tuple[str, list[HwaccelFamily]]: + """Get the hardware decoding this system can do. Args: detector_key: Hardware key of the detection hardware in use, which biases the recommendation toward that hardware's GPU + codecs: Codecs of the streams that will be decoded, used to drop + families that cannot decode one of them Returns: - The name of the preset that fits, or an empty string when none does + The recommended family key (empty when none fits) and every usable + family, best first """ - present = {found.key for found in hardware_prober.probe()} + wanted = {CODEC_ALIASES.get(codec, codec) for codec in codecs or set()} + hardware = _decode_hardware(detector_key) + generation = _intel_generation() if "openvino:GPU" in hardware else None - candidates: list[str] = [] + available: list[HwaccelFamily] = [] + recommended = "" - if detector_key in DECODE_PRESETS and detector_key in present: - candidates.append(detector_key) - elif detector_key == "openvino:NPU" and "openvino:GPU" in present: - # an Intel NPU decodes through the iGPU next to it - candidates.append("openvino:GPU") + for key in hardware: + usable = [ + family for family in _families(key, generation) if _decodes(family, wanted) + ] - candidates.extend(key for key in DECODE_PRESETS if key in present) + if usable and not recommended: + recommended = _recommend(usable, bool(wanted)) - if candidates: - preset = DECODE_PRESETS[candidates[0]] - return preset if preset is not None else _intel_preset() + for family in usable: + if family.key not in {entry.key for entry in available}: + available.append(family) - if _is_raspberry_pi(): - return PRESET_RPI + return recommended, available - return "" + +def _recommend(families: list[HwaccelFamily], codecs_known: bool) -> str: + """Pick the family to default to out of the ones this hardware can use.""" + if not codecs_known: + # nothing says which codec a camera will send, and a codec specific + # family would have to guess one, so anything that decodes them all wins + for family in families: + if ANY_CODEC in family.presets: + return family.key + + return families[0].key + + +def recommend_hwaccel( + detector_key: str | None = None, codecs: set[str] | None = None +) -> str: + """Recommend a hardware decoding family for this system. + + Args: + detector_key: Hardware key of the detection hardware in use + codecs: Codecs of the streams that will be decoded + + Returns: + The key of the family that fits, or an empty string when none does + """ + return hwaccel_options(detector_key, codecs)[0] diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index c2ceb7d6d1..54718e63ae 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -43,7 +43,10 @@ export interface ApiMockOverrides { configRaw?: string; configSchema?: Record; hardware?: unknown[]; - hwaccel?: { preset: string }; + hwaccel?: { + recommended: string; + available?: { key: string; presets: Record }[]; + }; } 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 diff --git a/web/e2e/specs/setup/wizard-hardware.spec.ts b/web/e2e/specs/setup/wizard-hardware.spec.ts index 43ef2e9e8e..4f1456ce8f 100644 --- a/web/e2e/specs/setup/wizard-hardware.spec.ts +++ b/web/e2e/specs/setup/wizard-hardware.spec.ts @@ -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, diff --git a/web/public/locales/en/views/setup.json b/web/public/locales/en/views/setup.json index dc2f80e8d7..8b55ee8e8b 100644 --- a/web/public/locales/en/views/setup.json +++ b/web/public/locales/en/views/setup.json @@ -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!", diff --git a/web/src/components/settings/CameraWizardDialog.tsx b/web/src/components/settings/CameraWizardDialog.tsx index a033f3a693..caf02bcd03 100644 --- a/web/src/components/settings/CameraWizardDialog.tsx +++ b/web/src/components/settings/CameraWizardDialog.tsx @@ -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 = {}; @@ -393,7 +404,7 @@ export default function CameraWizardDialog({ setIsLoading(false); }); }, - [updateConfig, t, onClose], + [updateConfig, t, onClose, onCameraAdded], ); return ( diff --git a/web/src/components/setup/SetupCamera.tsx b/web/src/components/setup/SetupCamera.tsx index e4a006c03c..ce740764ce 100644 --- a/web/src/components/setup/SetupCamera.tsx +++ b/web/src/components/setup/SetupCamera.tsx @@ -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, + ) => 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([]); + const [detectCodecs, setDetectCodecs] = useState>({}); const { mutate: mutateConfig } = useSWR("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) { - + ); } diff --git a/web/src/components/setup/SetupHwAccel.tsx b/web/src/components/setup/SetupHwAccel.tsx index 7dac5ca663..38141f0f84 100644 --- a/web/src/components/setup/SetupHwAccel.tsx +++ b/web/src/components/setup/SetupHwAccel.tsx @@ -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 = { 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; // 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( - detectorHardwareKey - ? `hardware/hwaccel?detector=${encodeURIComponent(detectorHardwareKey)}` - : "hardware/hwaccel", + query ? `hardware/hwaccel?${query}` : "hardware/hwaccel", { revalidateOnFocus: false }, ); const [selected, setSelected] = useState(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 | 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({

{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")}

- {HWACCEL_PRESETS.map((preset) => ( -
+ {families.map((family) => ( +
))} @@ -153,7 +236,7 @@ export default function SetupHwAccel({ className={radioClass(NONE)} />
diff --git a/web/src/components/setup/SetupRecording.tsx b/web/src/components/setup/SetupRecording.tsx index e97c987722..b8cbe3c99f 100644 --- a/web/src/components/setup/SetupRecording.tsx +++ b/web/src/components/setup/SetupRecording.tsx @@ -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(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 = { 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 (
@@ -89,6 +100,36 @@ export default function SetupRecording({ {enabled && ( <> +
+ + + {MODES.map((option) => ( +
+
+ + +
+

+ {t(`setupWizard.recording.modes.${option}.description`)} +

+
+ ))} +
+
+
- {freeGb !== null && estimatedDays !== null && cameraCount > 0 && ( -

- {t("setupWizard.recording.storageEstimate", { - free: freeGb, - days: estimatedDays, - cameras: cameraCount, - })} -

- )} + {mode === CONTINUOUS && + freeGb !== null && + estimatedDays !== null && + cameraCount > 0 && ( +

+ {t("setupWizard.recording.storageEstimate", { + free: freeGb, + days: estimatedDays, + cameras: cameraCount, + })} +

+ )} )} diff --git a/web/src/pages/SetupWizard.tsx b/web/src/pages/SetupWizard.tsx index e19dc46bd7..0ace4d2e9f 100644 --- a/web/src/pages/SetupWizard.tsx +++ b/web/src/pages/SetupWizard.tsx @@ -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; // 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; + } | { 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) => { + 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 ( ; +}; + export type HwaccelRecommendation = { - preset: string; + recommended: string; + available: HwaccelFamily[]; };