mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-27 08:54:42 +03:00
Compare commits
5
Commits
65ae5c3651
...
9c3d1e3ce2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3d1e3ce2 | ||
|
|
e495114310 | ||
|
|
a7b42b20e8 | ||
|
|
ebb6a316c8 | ||
|
|
2a4d48aa3c |
+13
-5
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the ffmpeg hwaccel preset recommendation."""
|
||||
"""Tests for the hardware decoding recommendation."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
@@ -38,102 +38,187 @@ 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):
|
||||
# each camera resolves the family to its own codec
|
||||
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__":
|
||||
|
||||
+11
-1
@@ -79,10 +79,20 @@ def redact_credential(obj: dict[str, Any], key: str) -> None:
|
||||
|
||||
|
||||
def find_config_file() -> str:
|
||||
"""Return the path of the config file to use.
|
||||
|
||||
Both .yml and .yaml are supported, so fall back to the other extension when
|
||||
the configured path does not exist. If neither exists the configured path is
|
||||
returned so a new config is created with the default .yml extension.
|
||||
"""
|
||||
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
||||
|
||||
if not os.path.isfile(config_path):
|
||||
config_path = config_path.replace("yml", "yaml")
|
||||
base, ext = os.path.splitext(config_path)
|
||||
alternate = f"{base}.yaml" if ext == ".yml" else f"{base}.yml"
|
||||
|
||||
if os.path.isfile(alternate):
|
||||
return alternate
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
+199
-49
@@ -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,78 @@ 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"
|
||||
ANY_CODEC = "any"
|
||||
|
||||
# marketing name of a newer Intel CPU, e.g. "13th Gen Intel(R) Core(TM) i5-13500"
|
||||
# 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"}
|
||||
|
||||
# e.g. "13th Gen Intel(R) Core(TM) i5-13500"
|
||||
INTEL_GEN_PATTERN = re.compile(r"(\d+)th Gen")
|
||||
# Core Ultra dropped the generation prefix and is newer than all of them
|
||||
INTEL_ULTRA_PATTERN = re.compile(r"Core\(TM\) Ultra")
|
||||
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,
|
||||
}
|
||||
# decode capable detection hardware, in recommendation priority 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 +109,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 +139,135 @@ 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]
|
||||
|
||||
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:
|
||||
# a codec specific family would have to guess a codec for cameras
|
||||
# that do not exist yet
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,16 +57,12 @@ async function captureRestarts(page: Page): Promise<string[]> {
|
||||
}
|
||||
|
||||
async function gotoDetectorStep(page: Page) {
|
||||
// welcome -> camera -> detector
|
||||
await page.getByRole("button", { name: "Get Started" }).click();
|
||||
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||
|
||||
// The camera step's Next button only renders once addedCameras is
|
||||
// non-empty, and that state is local to the step (it isn't seeded from
|
||||
// the cameras already in config). Opening and cancelling the add-camera
|
||||
// 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.
|
||||
// the camera step's Next only renders once its local addedCameras fills,
|
||||
// which SetupCamera does by refetching config when the dialog closes, so
|
||||
// opening and cancelling the dialog is what reveals Next
|
||||
await page.getByRole("button", { name: "Add Camera" }).click();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
@@ -85,7 +81,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);
|
||||
|
||||
@@ -100,7 +108,6 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
||||
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// detector -> hwaccel
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
|
||||
const detectorSave = saves.find((save) => save.config_data?.models);
|
||||
@@ -109,8 +116,8 @@ 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();
|
||||
// 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);
|
||||
@@ -118,11 +125,10 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
||||
hwaccel_args: "preset-vaapi",
|
||||
});
|
||||
|
||||
// hwaccel -> recording (skipped) -> complete, where the saved steps only
|
||||
// take effect after a restart and the primary action says so
|
||||
// the saved steps only take effect after a restart
|
||||
const restarts = await captureRestarts(page);
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("You're All Set!")).toBeVisible();
|
||||
await expect(page.getByText("You're done!")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText("Frigate needs to restart to apply your settings"),
|
||||
@@ -158,21 +164,20 @@ test.describe("setup wizard hardware @high @mobile", () => {
|
||||
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||
expect(saves.filter((save) => save.config_data?.models)).toHaveLength(0);
|
||||
|
||||
// with nothing derived, Auto advances without writing; skipping recording
|
||||
// leaves no pending saves, so finishing needs no restart
|
||||
// nothing derived and nothing saved, 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();
|
||||
await expect(page.getByText("You're done!")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText("Frigate needs to restart to apply your settings"),
|
||||
).toBeHidden();
|
||||
await page.getByRole("button", { name: "Go to Live View" }).click();
|
||||
|
||||
// hands off without restarting; the mocked config still reports
|
||||
// onboarding incomplete, so the reload lands back on the wizard
|
||||
// the mocked config still reports onboarding incomplete, so the reload
|
||||
// lands back on the wizard
|
||||
await expect(page.getByText("Welcome to Frigate")).toBeVisible();
|
||||
expect(restarts).toHaveLength(0);
|
||||
const finishSave = saves.find((save) => save.config_data?.onboarding);
|
||||
@@ -181,6 +186,81 @@ 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();
|
||||
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "NVIDIA Jetson" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// no camera was added, so there is no codec to match
|
||||
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,
|
||||
|
||||
@@ -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,14 +52,29 @@
|
||||
},
|
||||
"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 detected",
|
||||
"description": "Saves video around people, cars, and other objects Frigate detects. 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 detects is kept this long, then deleted automatically.",
|
||||
"continuous": "All video is kept this long, then deleted automatically."
|
||||
}
|
||||
},
|
||||
"complete": {
|
||||
"title": "You're All Set!",
|
||||
"title": "You're done!",
|
||||
"description": "Your Frigate system is configured. Here's what we set up:",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
|
||||
@@ -74,11 +74,14 @@ const STEPS = [
|
||||
type CameraWizardDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// lets callers reuse what was probed here instead of probing 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 +274,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 +403,7 @@ export default function CameraWizardDialog({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, t, onClose],
|
||||
[updateConfig, t, onClose, onCameraAdded],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,14 +18,15 @@ 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,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setShowWizard(false);
|
||||
// Delay to let CameraWizardDialog's config save and go2rtc setup complete,
|
||||
// then force a fresh fetch from the server (bypassing SWR cache)
|
||||
// the dialog's config save and go2rtc setup need a moment to land, and
|
||||
// the SWR cache would serve the pre-save config
|
||||
setTimeout(() => {
|
||||
fetch(`${window.baseUrl || ""}api/config`)
|
||||
.then((res) => res.json())
|
||||
@@ -33,15 +37,22 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
|
||||
setAddedCameras(cameraNames);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fetch failed, stay on this step
|
||||
});
|
||||
.catch(() => {});
|
||||
}, 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 +105,11 @@ export default function SetupCamera({ onNext, onBack }: SetupCameraProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CameraWizardDialog open={showWizard} onClose={handleClose} />
|
||||
<CameraWizardDialog
|
||||
open={showWizard}
|
||||
onClose={handleClose}
|
||||
onCameraAdded={handleCameraAdded}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ export default function SetupComplete({
|
||||
const handleFinish = useCallback(async () => {
|
||||
setFinishing(true);
|
||||
try {
|
||||
// Mark setup as complete
|
||||
await axios.put("config/set", {
|
||||
config_data: {
|
||||
onboarding: { setup_complete: true },
|
||||
@@ -95,8 +94,7 @@ export default function SetupComplete({
|
||||
requires_restart: 0,
|
||||
});
|
||||
|
||||
// camera adds were applied live, so with no pending requires_restart
|
||||
// saves the wizard can hand off without restarting
|
||||
// camera adds were applied live, so nothing is waiting on a restart
|
||||
if (!restartRequired) {
|
||||
window.location.href = window.baseUrl || "/";
|
||||
return;
|
||||
@@ -104,10 +102,8 @@ export default function SetupComplete({
|
||||
|
||||
setRestarting(true);
|
||||
|
||||
// Trigger restart
|
||||
await axios.post("restart");
|
||||
|
||||
// Poll for server availability with max retries
|
||||
let retries = 0;
|
||||
const maxRetries = 60; // 2 minutes max
|
||||
pollRef.current = setInterval(async () => {
|
||||
@@ -116,7 +112,6 @@ export default function SetupComplete({
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
}
|
||||
// Give up polling, redirect anyway
|
||||
window.location.href = window.baseUrl || "/";
|
||||
return;
|
||||
}
|
||||
@@ -129,7 +124,7 @@ export default function SetupComplete({
|
||||
window.location.href = window.baseUrl || "/";
|
||||
}
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
// not back yet
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
|
||||
@@ -20,8 +20,8 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
// detectors that ship no default model; configuring them without one leaves
|
||||
// the detector unable to start
|
||||
// these ship no default model, so configuring one without a model leaves the
|
||||
// detector unable to start
|
||||
const MODEL_REQUIRED_DETECTORS = ["onnx", "tensorrt"];
|
||||
|
||||
const CPU_FALLBACK: DetectionHardware[] = [
|
||||
@@ -64,7 +64,7 @@ export default function SetupDetector({
|
||||
});
|
||||
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(
|
||||
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
|
||||
[hardware],
|
||||
@@ -121,7 +121,6 @@ export default function SetupDetector({
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (needsModel && !plusModelId) {
|
||||
// no model to run on this hardware yet, so leave the config untouched
|
||||
onSkip(selected.key);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,19 +11,21 @@ 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;
|
||||
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;
|
||||
// saved reports whether a config write happened, so the wizard knows
|
||||
// whether finishing requires a restart
|
||||
// camera name -> detect stream codec, the only stream hwaccel applies to
|
||||
detectCodecs: Record<string, string>;
|
||||
// saved tells the wizard whether finishing needs a restart
|
||||
onNext: (saved: boolean) => void;
|
||||
onBack: () => void;
|
||||
onSkip: () => void;
|
||||
@@ -31,47 +33,118 @@ 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 } };
|
||||
}
|
||||
|
||||
const perCamera = cameraCodecs
|
||||
.map((entry) => ({ ...entry, preset: family.presets[entry.codec] }))
|
||||
.filter((entry) => entry.preset);
|
||||
|
||||
if (perCamera.length === 0) {
|
||||
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) {
|
||||
return { ffmpeg: { hwaccel_args: [...presets][0] } };
|
||||
}
|
||||
|
||||
// the global stays on auto so cameras added later resolve at startup
|
||||
// instead of inheriting one camera's codec
|
||||
return {
|
||||
cameras: Object.fromEntries(
|
||||
perCamera.map((entry) => [
|
||||
entry.camera,
|
||||
{ ffmpeg: { hwaccel_args: entry.preset } },
|
||||
]),
|
||||
),
|
||||
};
|
||||
},
|
||||
[cameraCodecs],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Auto with nothing derived writes nothing: the config default of "auto"
|
||||
// stays in place and the backend decides at startup
|
||||
if (selected === AUTO && !derived) {
|
||||
const key = selected === AUTO ? derived : selected;
|
||||
|
||||
const configData =
|
||||
selected === NONE
|
||||
? // an empty string would make config/set delete the key, reviving
|
||||
// the "auto" default
|
||||
{ ffmpeg: { hwaccel_args: [] } }
|
||||
: configFor(families.find((family) => family.key === key));
|
||||
|
||||
// nothing to write leaves the config default of "auto" in place
|
||||
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 +153,7 @@ export default function SetupHwAccel({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selected, derived, onNext, t]);
|
||||
}, [selected, derived, families, configFor, onNext, t]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -123,25 +196,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 +228,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>
|
||||
|
||||
@@ -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,12 +29,12 @@ 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);
|
||||
|
||||
const { data: stats } = useSWR("stats", { revalidateOnFocus: false });
|
||||
|
||||
// Calculate storage estimate
|
||||
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
|
||||
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
|
||||
const cameraCount = cameraNames.length;
|
||||
@@ -41,14 +47,17 @@ 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 } };
|
||||
// written even when off, so switching modes back turns it off again
|
||||
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 +66,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 +98,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 +138,29 @@ export default function SetupRecording({
|
||||
min={1}
|
||||
max={365}
|
||||
value={retentionDays}
|
||||
// drop the spinner arrows; typing and arrow keys still work
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import SetupDetector from "@/components/setup/SetupDetector";
|
||||
import SetupHwAccel from "@/components/setup/SetupHwAccel";
|
||||
import SetupRecording from "@/components/setup/SetupRecording";
|
||||
import SetupWelcome from "@/components/setup/SetupWelcome";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useTheme } from "@/context/theme-provider";
|
||||
import { useCallback, useReducer } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuMoon, LuSun } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
|
||||
@@ -24,8 +27,9 @@ type WizardState = {
|
||||
currentStep: number;
|
||||
cameraNames: string[];
|
||||
detectorHardwareKey?: string;
|
||||
// whether any step wrote config with requires_restart, which only takes
|
||||
// effect after a restart (camera adds apply live and don't count)
|
||||
// camera name -> detect stream codec
|
||||
detectCodecs: Record<string, string>;
|
||||
// camera adds apply live, so they don't count toward needing a restart
|
||||
restartRequired: boolean;
|
||||
configuredSteps: {
|
||||
camera: boolean;
|
||||
@@ -38,7 +42,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 +58,7 @@ type WizardAction =
|
||||
const initialState: WizardState = {
|
||||
currentStep: 0,
|
||||
cameraNames: [],
|
||||
detectCodecs: {},
|
||||
restartRequired: false,
|
||||
configuredSteps: {
|
||||
camera: false,
|
||||
@@ -73,6 +82,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":
|
||||
@@ -101,8 +111,11 @@ function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
||||
}
|
||||
|
||||
export default function SetupWizard() {
|
||||
const { t } = useTranslation(["views/setup"]);
|
||||
const { t } = useTranslation(["views/setup", "common"]);
|
||||
const [state, dispatch] = useReducer(wizardReducer, initialState);
|
||||
const { theme, systemTheme, setTheme } = useTheme();
|
||||
|
||||
const isDark = (theme === "system" ? systemTheme : theme) === "dark";
|
||||
|
||||
const handleSkipSetup = useCallback(async () => {
|
||||
try {
|
||||
@@ -118,13 +131,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 +194,7 @@ export default function SetupWizard() {
|
||||
return (
|
||||
<SetupHwAccel
|
||||
detectorHardwareKey={state.detectorHardwareKey}
|
||||
detectCodecs={state.detectCodecs}
|
||||
onNext={handleHwAccelNext}
|
||||
onBack={handleBack}
|
||||
onSkip={handleSkipStep}
|
||||
@@ -204,6 +225,19 @@ export default function SetupWizard() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background p-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed right-4 top-4 text-muted-foreground hover:text-primary"
|
||||
aria-label={t(isDark ? "menu.darkMode.light" : "menu.darkMode.dark", {
|
||||
ns: "common",
|
||||
})}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? <LuSun className="size-4" /> : <LuMoon className="size-4" />}
|
||||
</Button>
|
||||
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardContent className="p-6">
|
||||
<StepIndicator
|
||||
|
||||
@@ -12,6 +12,13 @@ export type DetectionHardware = {
|
||||
unlimited: boolean;
|
||||
};
|
||||
|
||||
export type HwaccelFamily = {
|
||||
key: string;
|
||||
// keyed by codec, or a single "any" preset when it decodes every codec
|
||||
presets: Record<string, string>;
|
||||
};
|
||||
|
||||
export type HwaccelRecommendation = {
|
||||
preset: string;
|
||||
recommended: string;
|
||||
available: HwaccelFamily[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user