mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 14:52:47 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f25d785380 | ||
|
|
ff71e823c8 | ||
|
|
1f9ed9eaaa | ||
|
|
5670d05aa6 | ||
|
|
b8cfe68d26 | ||
|
|
d02811d2b4 | ||
|
|
bb5e9264b0 |
Vendored
+89
@@ -4053,6 +4053,58 @@ paths:
|
|||||||
security:
|
security:
|
||||||
- frigateAdminAuth: []
|
- frigateAdminAuth: []
|
||||||
x-required-role: admin
|
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:
|
/events:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -8670,6 +8722,43 @@ components:
|
|||||||
- label
|
- label
|
||||||
title: HardwareUnit
|
title: HardwareUnit
|
||||||
description: One physical piece of hardware.
|
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:
|
Last24HoursReview:
|
||||||
properties:
|
properties:
|
||||||
reviewed_alert:
|
reviewed_alert:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from frigate.api.auth import require_role
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.detectors.hardware import DetectionHardware, hardware_prober
|
from frigate.detectors.hardware import DetectionHardware, hardware_prober
|
||||||
|
from frigate.util.hwaccel import HwaccelRecommendation, hwaccel_options
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,3 +29,29 @@ def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
|
|||||||
Every kind of detection hardware that was found
|
Every kind of detection hardware that was found
|
||||||
"""
|
"""
|
||||||
return hardware_prober.probe(refresh=refresh)
|
return hardware_prober.probe(refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/hardware/hwaccel",
|
||||||
|
response_model=HwaccelRecommendation,
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
|
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 family (empty when none fits) and every usable family
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Tests for the hardware decoding recommendation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from frigate.detectors.hardware import DetectionHardware
|
||||||
|
from frigate.util import hwaccel
|
||||||
|
|
||||||
|
|
||||||
|
def found(key: str) -> DetectionHardware:
|
||||||
|
"""A probe result carrying only the fields the recommendation reads."""
|
||||||
|
return DetectionHardware(
|
||||||
|
key=key,
|
||||||
|
detector=key.partition(":")[0],
|
||||||
|
name=key,
|
||||||
|
units=[],
|
||||||
|
count=0,
|
||||||
|
unlimited=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HwaccelRecommendationTestCase(unittest.TestCase):
|
||||||
|
"""Points every read at an empty fixture tree, so nothing is found by default."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.root = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.root.cleanup)
|
||||||
|
|
||||||
|
self.proc_root = os.path.join(self.root.name, "proc")
|
||||||
|
os.makedirs(self.proc_root)
|
||||||
|
patcher = patch.object(hwaccel, "PROC_ROOT", self.proc_root)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
drm = patch.object(hwaccel, "enumerate_drm_devices", return_value={})
|
||||||
|
self.drm = drm.start()
|
||||||
|
self.addCleanup(drm.stop)
|
||||||
|
|
||||||
|
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.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"]), "nvidia")
|
||||||
|
|
||||||
|
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"]), "rkmpp")
|
||||||
|
|
||||||
|
def test_an_amd_gpu_uses_vaapi(self):
|
||||||
|
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"), "vaapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_chosen_npu_decodes_through_the_igpu(self):
|
||||||
|
self.assertEqual(
|
||||||
|
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"), "vaapi")
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntelGeneration(HwaccelRecommendationTestCase):
|
||||||
|
def test_the_xe_driver_prefers_qsv(self):
|
||||||
|
self.drm.return_value = {"0000:00:02.0": "xe"}
|
||||||
|
self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv")
|
||||||
|
|
||||||
|
def test_gen13_prefers_qsv(self):
|
||||||
|
self.write_cpuinfo("13th Gen Intel(R) Core(TM) i5-13500")
|
||||||
|
self.assertEqual(self.recommend(["openvino:GPU"], codecs={"h264"}), "intel-qsv")
|
||||||
|
|
||||||
|
def test_a_core_ultra_prefers_qsv(self):
|
||||||
|
self.write_cpuinfo("Intel(R) Core(TM) Ultra 7 155H")
|
||||||
|
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 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_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.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"]), "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__":
|
||||||
|
unittest.main()
|
||||||
+11
-1
@@ -79,10 +79,20 @@ def redact_credential(obj: dict[str, Any], key: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def find_config_file() -> str:
|
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)
|
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
||||||
|
|
||||||
if not os.path.isfile(config_path):
|
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
|
return config_path
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""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
|
||||||
|
import re
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from frigate.const import (
|
||||||
|
FFMPEG_HWACCEL_NVIDIA,
|
||||||
|
FFMPEG_HWACCEL_RKMPP,
|
||||||
|
FFMPEG_HWACCEL_VAAPI,
|
||||||
|
)
|
||||||
|
from frigate.detectors.hardware import hardware_prober
|
||||||
|
from frigate.util.services import enumerate_drm_devices
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# root the /proc reads use, so tests can point them at a fixture tree
|
||||||
|
PROC_ROOT = "/proc"
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
# 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 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
|
||||||
|
|
||||||
|
# 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 hardware decoding this system can do."""
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Read a small file, returning None if it cannot be read."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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 INTEL_GEN_LATEST
|
||||||
|
|
||||||
|
cpuinfo = _read(f"{PROC_ROOT}/cpuinfo") or ""
|
||||||
|
|
||||||
|
for line in cpuinfo.splitlines():
|
||||||
|
if not line.startswith("model name"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = INTEL_GEN_PATTERN.search(line)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
|
||||||
|
if INTEL_ULTRA_PATTERN.search(line):
|
||||||
|
return INTEL_GEN_LATEST
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_raspberry_pi() -> bool:
|
||||||
|
compatible = _read(f"{PROC_ROOT}/device-tree/compatible") or ""
|
||||||
|
return "raspberrypi" in compatible
|
||||||
|
|
||||||
|
|
||||||
|
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 recommended family key (empty when none fits) and every usable
|
||||||
|
family, best first
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
available: list[HwaccelFamily] = []
|
||||||
|
recommended = ""
|
||||||
|
|
||||||
|
for key in hardware:
|
||||||
|
usable = [
|
||||||
|
family for family in _families(key, generation) if _decodes(family, wanted)
|
||||||
|
]
|
||||||
|
|
||||||
|
if usable and not recommended:
|
||||||
|
recommended = _recommend(usable, bool(wanted))
|
||||||
|
|
||||||
|
for family in usable:
|
||||||
|
if family.key not in {entry.key for entry in available}:
|
||||||
|
available.append(family)
|
||||||
|
|
||||||
|
return recommended, available
|
||||||
|
|
||||||
|
|
||||||
|
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,6 +43,10 @@ export interface ApiMockOverrides {
|
|||||||
configRaw?: string;
|
configRaw?: string;
|
||||||
configSchema?: Record<string, unknown>;
|
configSchema?: Record<string, unknown>;
|
||||||
hardware?: unknown[];
|
hardware?: unknown[];
|
||||||
|
hwaccel?: {
|
||||||
|
recommended: string;
|
||||||
|
available?: { key: string; presets: Record<string, string> }[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiMocker {
|
export class ApiMocker {
|
||||||
@@ -185,6 +189,17 @@ export class ApiMocker {
|
|||||||
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Hwaccel preset recommendation
|
||||||
|
await this.page.route("**/api/hardware/hwaccel**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
recommended: "",
|
||||||
|
available: [],
|
||||||
|
...(overrides?.hwaccel ?? {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Go2RTC streams
|
// Go2RTC streams
|
||||||
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
||||||
route.fulfill({ json: {} }),
|
route.fulfill({ json: {} }),
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Setup wizard hardware tests -- HIGH tier.
|
||||||
|
*
|
||||||
|
* Covers the detector step's probed radio list and the models: payload it
|
||||||
|
* writes, the model-required deferral for onnx hardware, the hwaccel step's
|
||||||
|
* Auto option writing the preset derived from the chosen hardware, and the
|
||||||
|
* completion screen only restarting when a saved step requires it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { configFactory } from "../../fixtures/mock-data/config";
|
||||||
|
import type { ApiMockOverrides } from "../../helpers/api-mocker";
|
||||||
|
|
||||||
|
const NVIDIA_HARDWARE = [
|
||||||
|
{
|
||||||
|
key: "onnx:nvidia",
|
||||||
|
detector: "onnx",
|
||||||
|
name: "NVIDIA GeForce RTX 3060",
|
||||||
|
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
|
||||||
|
count: 1,
|
||||||
|
unlimited: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cpu",
|
||||||
|
detector: "cpu",
|
||||||
|
name: "CPU",
|
||||||
|
units: [{ device: "cpu", label: "CPU" }],
|
||||||
|
count: 1,
|
||||||
|
unlimited: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type SavedConfig = {
|
||||||
|
config_data?: {
|
||||||
|
models?: { devices: string[]; path?: string }[];
|
||||||
|
detect?: { enabled?: boolean };
|
||||||
|
ffmpeg?: { hwaccel_args?: string | string[] };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function captureSaves(page: Page): Promise<SavedConfig[]> {
|
||||||
|
const saves: SavedConfig[] = [];
|
||||||
|
await page.route("**/api/config/set**", (route) => {
|
||||||
|
saves.push(route.request().postDataJSON() as SavedConfig);
|
||||||
|
return route.fulfill({ json: { success: true, require_restart: true } });
|
||||||
|
});
|
||||||
|
return saves;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureRestarts(page: Page): Promise<string[]> {
|
||||||
|
const calls: string[] = [];
|
||||||
|
await page.route("**/api/restart", (route) => {
|
||||||
|
calls.push(route.request().url());
|
||||||
|
return route.fulfill({ json: { success: true, message: "Restarting" } });
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install mocks for a first run: the wizard shows when config has no cameras,
|
||||||
|
* so the config route starts empty and only reports cameras once the returned
|
||||||
|
* callback is fired, standing in for the camera the user adds.
|
||||||
|
*/
|
||||||
|
async function installFirstRun(
|
||||||
|
frigateApp: { installDefaults: (o?: ApiMockOverrides) => Promise<void> },
|
||||||
|
page: Page,
|
||||||
|
overrides?: ApiMockOverrides,
|
||||||
|
): Promise<() => void> {
|
||||||
|
await frigateApp.installDefaults(overrides);
|
||||||
|
|
||||||
|
const full = configFactory(overrides?.config);
|
||||||
|
let cameras: unknown = {};
|
||||||
|
|
||||||
|
await page.route("**/api/config", (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
return route.fulfill({ json: { ...full, cameras } });
|
||||||
|
}
|
||||||
|
return route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cameras = full.cameras;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoDetectorStep(page: Page, addCamera: () => void) {
|
||||||
|
await page.getByRole("button", { name: "Get Started" }).click();
|
||||||
|
await expect(page.getByText("Add Your First Camera")).toBeVisible();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
addCamera();
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Next" })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
await expect(page.getByText("Object Detection")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("setup wizard hardware @high @mobile", () => {
|
||||||
|
test("lists probed hardware and writes a models config", async ({
|
||||||
|
frigateApp,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
|
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);
|
||||||
|
|
||||||
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
|
await gotoDetectorStep(page, addCamera);
|
||||||
|
|
||||||
|
// the default hardware mock reports two Corals, an Intel GPU, and the CPU
|
||||||
|
await expect(
|
||||||
|
page.getByRole("radio", { name: /Coral EdgeTPU \(PCIe\) \(2\)/ }),
|
||||||
|
).toBeChecked();
|
||||||
|
await expect(page.getByText("Recommended")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||||
|
|
||||||
|
const detectorSave = saves.find((save) => save.config_data?.models);
|
||||||
|
expect(detectorSave?.config_data?.models).toEqual([
|
||||||
|
{ devices: ["edgetpu:pci:0"] },
|
||||||
|
]);
|
||||||
|
expect(detectorSave?.config_data?.detect).toEqual({ enabled: true });
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({
|
||||||
|
hwaccel_args: "preset-vaapi",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 done!")).toBeVisible();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText("Frigate needs to restart to apply your settings"),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Apply & Restart" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Starting Frigate...")).toBeVisible();
|
||||||
|
expect(restarts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defers model setup for onnx hardware without Frigate+", async ({
|
||||||
|
frigateApp,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
|
hardware: NVIDIA_HARDWARE,
|
||||||
|
});
|
||||||
|
const saves = await captureSaves(page);
|
||||||
|
|
||||||
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
|
await gotoDetectorStep(page, addCamera);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("radio", { name: /NVIDIA GeForce RTX 3060/ }),
|
||||||
|
).toBeChecked();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Continue without detection" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// advances without touching the config
|
||||||
|
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||||
|
expect(saves.filter((save) => save.config_data?.models)).toHaveLength(0);
|
||||||
|
|
||||||
|
// nothing derived and nothing saved, so finishing needs no restart
|
||||||
|
const restarts = await captureRestarts(page);
|
||||||
|
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 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, and the wizard does not come back
|
||||||
|
await expect(page.getByText("Welcome to Frigate")).toBeHidden();
|
||||||
|
expect(restarts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers only the presets the hardware supports", async ({
|
||||||
|
frigateApp,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
|
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, addCamera);
|
||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
const addCamera = await installFirstRun(frigateApp, page, {
|
||||||
|
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, addCamera);
|
||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
const addCamera = await installFirstRun(frigateApp, page);
|
||||||
|
const saves = await captureSaves(page);
|
||||||
|
|
||||||
|
await frigateApp.gotoAndWait("/", "text=Welcome to Frigate");
|
||||||
|
await gotoDetectorStep(page, addCamera);
|
||||||
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
await expect(page.getByText("Hardware Acceleration")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("radio", { name: "None (software decoding)" }).click();
|
||||||
|
await page.getByRole("button", { name: "Next" }).click();
|
||||||
|
|
||||||
|
const hwaccelSave = saves.find((save) => save.config_data?.ffmpeg);
|
||||||
|
expect(hwaccelSave?.config_data?.ffmpeg).toEqual({ hwaccel_args: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"setupWizard": {
|
||||||
|
"steps": {
|
||||||
|
"welcome": "Welcome",
|
||||||
|
"camera": "Add Camera",
|
||||||
|
"hwaccel": "Acceleration",
|
||||||
|
"detector": "Detection",
|
||||||
|
"recording": "Recording",
|
||||||
|
"complete": "Done"
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"title": "Welcome to Frigate",
|
||||||
|
"description": "Let's get your security cameras set up. We'll walk through camera connection, hardware settings, and recording.",
|
||||||
|
"getStarted": "Get Started",
|
||||||
|
"skipSetup": "Skip setup and configure manually"
|
||||||
|
},
|
||||||
|
"camera": {
|
||||||
|
"title": "Add Your First Camera",
|
||||||
|
"description": "Connect a camera to start monitoring. You can also add more cameras later in Settings.",
|
||||||
|
"addCamera": "Add Camera",
|
||||||
|
"addAnother": "Add Another Camera",
|
||||||
|
"cameraAdded": "Camera added successfully",
|
||||||
|
"retry": "Try Again"
|
||||||
|
},
|
||||||
|
"hwaccel": {
|
||||||
|
"title": "Hardware Acceleration",
|
||||||
|
"description": "Speed up video decoding with your GPU.",
|
||||||
|
"detecting": "Checking your hardware...",
|
||||||
|
"auto": "Auto",
|
||||||
|
"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.",
|
||||||
|
"families": {
|
||||||
|
"nvidia": "CUDA (NVIDIA)",
|
||||||
|
"vaapi": "VAAPI (Intel/AMD)",
|
||||||
|
"intel-qsv": "QuickSync (Intel)",
|
||||||
|
"rkmpp": "RKMPP (Rockchip)",
|
||||||
|
"jetson": "NVIDIA Jetson",
|
||||||
|
"rpi": "V4L2 (Raspberry Pi)",
|
||||||
|
"none": "None (software decoding)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detector": {
|
||||||
|
"title": "Object Detection",
|
||||||
|
"description": "Choose the hardware Frigate uses to detect people and objects.",
|
||||||
|
"detecting": "Checking for detection hardware...",
|
||||||
|
"probeFailed": "Hardware detection is unavailable. You can configure detection later in Settings.",
|
||||||
|
"recommended": "Recommended",
|
||||||
|
"modelRequired": "{{name}} needs a detection model before it can run. Pick a Frigate+ model, or finish setup and add one under Settings > Detection models.",
|
||||||
|
"plusModelPlaceholder": "Select a Frigate+ model",
|
||||||
|
"continueWithout": "Continue without detection"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"title": "Recordings",
|
||||||
|
"description": "Save video from your cameras so you can watch it later.",
|
||||||
|
"enableRecording": "Enable recordings",
|
||||||
|
"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 done!",
|
||||||
|
"description": "Your Frigate system is configured. Here's what we set up:",
|
||||||
|
"configured": "Configured",
|
||||||
|
"notConfigured": "Not configured",
|
||||||
|
"configureInSettings": "Configure in Settings",
|
||||||
|
"camera": "Camera",
|
||||||
|
"hwaccel": "Hardware Acceleration",
|
||||||
|
"detector": "Object Detection",
|
||||||
|
"recording": "Recording",
|
||||||
|
"goToLiveView": "Go to Live View",
|
||||||
|
"applyAndRestart": "Apply & Restart",
|
||||||
|
"restartNotice": "Frigate needs to restart to apply your settings. This takes about 30 seconds.",
|
||||||
|
"nextSteps": "Next steps: Set up motion masks, zones, and notifications in Settings.",
|
||||||
|
"restarting": "Starting Frigate...",
|
||||||
|
"restartingDescription": "This takes about 30 seconds."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"next": "Next",
|
||||||
|
"back": "Back",
|
||||||
|
"skip": "Skip",
|
||||||
|
"saving": "Saving..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"saveFailed": "Failed to save configuration. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-1
@@ -6,7 +6,7 @@ import Sidebar from "@/components/navigation/Sidebar";
|
|||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
import Bottombar from "./components/navigation/Bottombar";
|
import Bottombar from "./components/navigation/Bottombar";
|
||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy, useEffect, useState } from "react";
|
||||||
import { Redirect } from "./components/navigation/Redirect";
|
import { Redirect } from "./components/navigation/Redirect";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
import { isPWA } from "./utils/isPWA";
|
import { isPWA } from "./utils/isPWA";
|
||||||
@@ -15,6 +15,7 @@ import useSWR from "swr";
|
|||||||
import { FrigateConfig } from "./types/frigateConfig";
|
import { FrigateConfig } from "./types/frigateConfig";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { isRedirectingToLogin } from "@/api/auth-redirect";
|
import { isRedirectingToLogin } from "@/api/auth-redirect";
|
||||||
|
import { isSetupDismissed } from "@/utils/setupWizard";
|
||||||
|
|
||||||
const Live = lazy(() => import("@/pages/Live"));
|
const Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
const Events = lazy(() => import("@/pages/Events"));
|
||||||
@@ -30,6 +31,7 @@ const Chat = lazy(() => import("@/pages/Chat"));
|
|||||||
const Logs = lazy(() => import("@/pages/Logs"));
|
const Logs = lazy(() => import("@/pages/Logs"));
|
||||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||||
const Replay = lazy(() => import("@/pages/Replay"));
|
const Replay = lazy(() => import("@/pages/Replay"));
|
||||||
|
const SetupWizard = lazy(() => import("@/pages/SetupWizard"));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@@ -52,6 +54,18 @@ function DefaultAppView() {
|
|||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// decided once per load: adding the first camera part way through the
|
||||||
|
// wizard must not pull the wizard out from under the user
|
||||||
|
const [showWizard, setShowWizard] = useState<boolean>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && showWizard === undefined) {
|
||||||
|
setShowWizard(
|
||||||
|
Object.keys(config.cameras ?? {}).length === 0 && !isSetupDismissed(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [config, showWizard]);
|
||||||
|
|
||||||
// Compute required roles for main routes, ensuring we have config first
|
// Compute required roles for main routes, ensuring we have config first
|
||||||
// to prevent race condition where custom roles are temporarily unavailable
|
// to prevent race condition where custom roles are temporarily unavailable
|
||||||
const mainRouteRoles = config?.auth?.roles
|
const mainRouteRoles = config?.auth?.roles
|
||||||
@@ -68,6 +82,21 @@ function DefaultAppView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show setup wizard for first-time users
|
||||||
|
if (showWizard) {
|
||||||
|
return (
|
||||||
|
<div className="size-full overflow-hidden">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SetupWizard />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
{isDesktop && <Sidebar />}
|
{isDesktop && <Sidebar />}
|
||||||
|
|||||||
@@ -74,11 +74,14 @@ const STEPS = [
|
|||||||
type CameraWizardDialogProps = {
|
type CameraWizardDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
// lets callers reuse what was probed here instead of probing again
|
||||||
|
onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CameraWizardDialog({
|
export default function CameraWizardDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
onCameraAdded,
|
||||||
}: CameraWizardDialogProps) {
|
}: CameraWizardDialogProps) {
|
||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const { mutate: updateConfig } = useSWR("config");
|
const { mutate: updateConfig } = useSWR("config");
|
||||||
@@ -271,6 +274,13 @@ export default function CameraWizardDialog({
|
|||||||
.put("config/set", requestBody)
|
.put("config/set", requestBody)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
onCameraAdded?.({
|
||||||
|
name: finalCameraName,
|
||||||
|
detectCodec: wizardData.streams?.find((stream) =>
|
||||||
|
stream.roles.includes("detect"),
|
||||||
|
)?.testResult?.videoCodec,
|
||||||
|
});
|
||||||
|
|
||||||
// Configure go2rtc streams for all streams
|
// Configure go2rtc streams for all streams
|
||||||
if (wizardData.streams && wizardData.streams.length > 0) {
|
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||||
const go2rtcStreams: Record<string, string[]> = {};
|
const go2rtcStreams: Record<string, string[]> = {};
|
||||||
@@ -393,7 +403,7 @@ export default function CameraWizardDialog({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[updateConfig, t, onClose],
|
[updateConfig, t, onClose, onCameraAdded],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
|
|
||||||
|
type SetupCameraProps = {
|
||||||
|
onNext: (
|
||||||
|
cameraNames?: string[],
|
||||||
|
detectCodecs?: Record<string, string>,
|
||||||
|
) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
// 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())
|
||||||
|
.then((freshConfig: FrigateConfig) => {
|
||||||
|
const cameraNames = Object.keys(freshConfig.cameras || {});
|
||||||
|
if (cameraNames.length > addedCameras.length) {
|
||||||
|
mutateConfig(freshConfig, { revalidate: false });
|
||||||
|
setAddedCameras(cameraNames);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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, detectCodecs);
|
||||||
|
}, [onNext, addedCameras, detectCodecs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t("setupWizard.camera.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.camera.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addedCameras.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{addedCameras.map((name) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex items-center justify-between rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{name}</span>
|
||||||
|
<FaCircleCheck className="size-4 text-success" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-3 py-4">
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
>
|
||||||
|
{addedCameras.length > 0
|
||||||
|
? t("setupWizard.camera.addAnother")
|
||||||
|
: t("setupWizard.camera.addCamera")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack}>
|
||||||
|
{t("setupWizard.actions.back")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-1 justify-end gap-3">
|
||||||
|
{addedCameras.length > 0 && (
|
||||||
|
<Button type="button" variant="select" onClick={handleNext}>
|
||||||
|
{t("setupWizard.actions.next")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CameraWizardDialog
|
||||||
|
open={showWizard}
|
||||||
|
onClose={handleClose}
|
||||||
|
onCameraAdded={handleCameraAdded}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import axios from "axios";
|
||||||
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
|
import { dismissSetup } from "@/utils/setupWizard";
|
||||||
|
|
||||||
|
type ConfiguredItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetupCompleteProps = {
|
||||||
|
cameraNames: string[];
|
||||||
|
configuredSteps: {
|
||||||
|
camera: boolean;
|
||||||
|
hwaccel: boolean;
|
||||||
|
detector: boolean;
|
||||||
|
recording: boolean;
|
||||||
|
};
|
||||||
|
restartRequired: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SetupComplete({
|
||||||
|
cameraNames,
|
||||||
|
configuredSteps,
|
||||||
|
restartRequired,
|
||||||
|
onBack,
|
||||||
|
}: SetupCompleteProps) {
|
||||||
|
const { t } = useTranslation(["views/setup"]);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [finishing, setFinishing] = useState(false);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const cameraItems: ConfiguredItem[] =
|
||||||
|
configuredSteps.camera && cameraNames.length > 0
|
||||||
|
? cameraNames.map((name) => ({
|
||||||
|
key: `camera-${name}`,
|
||||||
|
label: t("setupWizard.complete.camera"),
|
||||||
|
value: name,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "camera",
|
||||||
|
label: t("setupWizard.complete.camera"),
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const items: ConfiguredItem[] = [
|
||||||
|
...cameraItems,
|
||||||
|
{
|
||||||
|
key: "hwaccel",
|
||||||
|
label: t("setupWizard.complete.hwaccel"),
|
||||||
|
value: configuredSteps.hwaccel
|
||||||
|
? t("setupWizard.complete.configured")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "detector",
|
||||||
|
label: t("setupWizard.complete.detector"),
|
||||||
|
value: configuredSteps.detector
|
||||||
|
? t("setupWizard.complete.configured")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "recording",
|
||||||
|
label: t("setupWizard.complete.recording"),
|
||||||
|
value: configuredSteps.recording
|
||||||
|
? t("setupWizard.complete.configured")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFinish = useCallback(async () => {
|
||||||
|
setFinishing(true);
|
||||||
|
dismissSetup();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// camera adds were applied live, so nothing is waiting on a restart
|
||||||
|
if (!restartRequired) {
|
||||||
|
window.location.href = window.baseUrl || "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRestarting(true);
|
||||||
|
|
||||||
|
await axios.post("restart");
|
||||||
|
|
||||||
|
let retries = 0;
|
||||||
|
const maxRetries = 60; // 2 minutes max
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
retries++;
|
||||||
|
if (retries > maxRetries) {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
}
|
||||||
|
window.location.href = window.baseUrl || "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await axios.get("version", { timeout: 2000 });
|
||||||
|
if (resp.status === 200) {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
}
|
||||||
|
window.location.href = window.baseUrl || "/";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not back yet
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch {
|
||||||
|
setRestarting(false);
|
||||||
|
setFinishing(false);
|
||||||
|
toast.error(t("setupWizard.errors.saveFailed"));
|
||||||
|
}
|
||||||
|
}, [restartRequired, t]);
|
||||||
|
|
||||||
|
if (restarting) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6 py-12">
|
||||||
|
<Logo className="h-12 w-12" />
|
||||||
|
<ActivityIndicator />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t("setupWizard.complete.restarting")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.complete.restartingDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t("setupWizard.complete.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.complete.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex items-center justify-between rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.value && <FaCircleCheck className="size-4 text-success" />}
|
||||||
|
<span
|
||||||
|
className={`text-sm ${item.value ? "" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{item.value ?? t("setupWizard.complete.notConfigured")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.complete.nextSteps")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{restartRequired && (
|
||||||
|
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.complete.restartNotice")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack}>
|
||||||
|
{t("setupWizard.actions.back")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-1 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="select"
|
||||||
|
onClick={handleFinish}
|
||||||
|
disabled={finishing}
|
||||||
|
>
|
||||||
|
{restartRequired
|
||||||
|
? t("setupWizard.complete.applyAndRestart")
|
||||||
|
: t("setupWizard.complete.goToLiveView")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import type { FrigatePlusModel } from "@/components/config-form/theme/fields/ModelSourcePicker";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import type { DetectionHardware } from "@/types/hardware";
|
||||||
|
import { recommendedDetectorCount } from "@/utils/detectionHardware";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// 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[] = [
|
||||||
|
{
|
||||||
|
key: "cpu",
|
||||||
|
detector: "cpu",
|
||||||
|
name: "CPU",
|
||||||
|
units: [{ device: "cpu", label: "CPU" }],
|
||||||
|
count: 1,
|
||||||
|
unlimited: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type SetupDetectorProps = {
|
||||||
|
cameraCount: number;
|
||||||
|
onNext: (hardwareKey: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: (hardwareKey?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SetupDetector({
|
||||||
|
cameraCount,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip,
|
||||||
|
}: SetupDetectorProps) {
|
||||||
|
const { t } = useTranslation(["views/setup", "common"]);
|
||||||
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: hardware,
|
||||||
|
isLoading,
|
||||||
|
error: probeError,
|
||||||
|
} = useSWR<DetectionHardware[]>("hardware/probe", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const plusEnabled = Boolean(config?.plus?.enabled);
|
||||||
|
|
||||||
|
// the cpu is always probed, so an empty list means the probe failed
|
||||||
|
const options = useMemo(
|
||||||
|
() => (hardware && hardware.length > 0 ? hardware : CPU_FALLBACK),
|
||||||
|
[hardware],
|
||||||
|
);
|
||||||
|
|
||||||
|
// the prober orders accelerators ahead of the cpu
|
||||||
|
const recommendedKey = options[0].key;
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string>();
|
||||||
|
const selected =
|
||||||
|
options.find((entry) => entry.key === (selectedKey ?? recommendedKey)) ??
|
||||||
|
options[0];
|
||||||
|
|
||||||
|
const needsModel = MODEL_REQUIRED_DETECTORS.includes(selected.detector);
|
||||||
|
|
||||||
|
const { data: plusModels } = useSWR<FrigatePlusModel[]>(
|
||||||
|
plusEnabled && needsModel ? "/plus/models" : null,
|
||||||
|
{
|
||||||
|
fetcher: async (url) => {
|
||||||
|
const res = await axios.get(url, { withCredentials: true });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [plusModelId, setPlusModelId] = useState("");
|
||||||
|
|
||||||
|
const compatiblePlusModels = useMemo(
|
||||||
|
() =>
|
||||||
|
(plusModels ?? []).filter((model) =>
|
||||||
|
model.supportedDetectors.includes(selected.detector),
|
||||||
|
),
|
||||||
|
[plusModels, selected.detector],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const buildDevices = useCallback(
|
||||||
|
(entry: DetectionHardware): string[] => {
|
||||||
|
const first = entry.units[0]?.device;
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.unlimited) {
|
||||||
|
return [first];
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeating a device runs an extra inference process on it
|
||||||
|
const count = recommendedDetectorCount(Math.max(cameraCount, 1));
|
||||||
|
return Array.from({ length: count }, () => first);
|
||||||
|
},
|
||||||
|
[cameraCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (needsModel && !plusModelId) {
|
||||||
|
onSkip(selected.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const model: Record<string, unknown> = {
|
||||||
|
devices: buildDevices(selected),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (needsModel) {
|
||||||
|
model.path = `plus://${plusModelId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.put("config/set", {
|
||||||
|
config_data: {
|
||||||
|
models: [model],
|
||||||
|
detect: { enabled: true },
|
||||||
|
},
|
||||||
|
requires_restart: 1,
|
||||||
|
});
|
||||||
|
onNext(selected.key);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("setupWizard.errors.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [needsModel, plusModelId, selected, buildDevices, onNext, onSkip, t]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-12">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.detector.detecting")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t("setupWizard.detector.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.detector.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{probeError && (
|
||||||
|
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.detector.probeFailed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={selected.key}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedKey(value);
|
||||||
|
setPlusModelId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((entry) => (
|
||||||
|
<div key={entry.key} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={entry.key}
|
||||||
|
id={`detector-${entry.key}`}
|
||||||
|
className={
|
||||||
|
selected.key === entry.key
|
||||||
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`detector-${entry.key}`}
|
||||||
|
className="cursor-pointer text-sm font-medium"
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
{entry.count > 1 ? ` (${entry.count})` : ""}
|
||||||
|
{entry.key === recommendedKey && entry.key !== "cpu" && (
|
||||||
|
<span className="ml-2 text-xs text-selected">
|
||||||
|
{t("setupWizard.detector.recommended")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{needsModel && (
|
||||||
|
<div className="flex flex-col gap-3 rounded-md bg-muted p-3 text-sm">
|
||||||
|
<p>
|
||||||
|
{t("setupWizard.detector.modelRequired", { name: selected.name })}
|
||||||
|
</p>
|
||||||
|
{plusEnabled ? (
|
||||||
|
<Select value={plusModelId} onValueChange={setPlusModelId}>
|
||||||
|
<SelectTrigger className="max-w-xs">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("setupWizard.detector.plusModelPlaceholder")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{compatiblePlusModels.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{`${model.name} (${model.width}x${model.height})`}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={getLocaleDocUrl("configuration/object_detectors")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-primary"
|
||||||
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 size-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack}>
|
||||||
|
{t("setupWizard.actions.back")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-1 justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onSkip(selected.key)}
|
||||||
|
>
|
||||||
|
{t("setupWizard.actions.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="select"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? t("setupWizard.actions.saving")
|
||||||
|
: needsModel && !plusModelId
|
||||||
|
? t("setupWizard.detector.continueWithout")
|
||||||
|
: t("setupWizard.actions.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import type { HwaccelFamily, HwaccelRecommendation } from "@/types/hardware";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
const AUTO = "auto";
|
||||||
|
const NONE = "none";
|
||||||
|
|
||||||
|
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;
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>(
|
||||||
|
query ? `hardware/hwaccel?${query}` : "hardware/hwaccel",
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string>(AUTO);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await axios.put("config/set", {
|
||||||
|
config_data: configData,
|
||||||
|
requires_restart: 1,
|
||||||
|
});
|
||||||
|
onNext(true);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("setupWizard.errors.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [selected, derived, families, configFor, onNext, t]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-12">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.hwaccel.detecting")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radioClass = (value: string) =>
|
||||||
|
selected === value
|
||||||
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t("setupWizard.hwaccel.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.hwaccel.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RadioGroup value={selected} onValueChange={setSelected}>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={AUTO}
|
||||||
|
id="hwaccel-auto"
|
||||||
|
className={radioClass(AUTO)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="hwaccel-auto" className="cursor-pointer text-sm">
|
||||||
|
{t("setupWizard.hwaccel.auto")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="ml-6 text-xs text-muted-foreground">
|
||||||
|
{derived
|
||||||
|
? t("setupWizard.hwaccel.autoResolved", {
|
||||||
|
family: t(`setupWizard.hwaccel.families.${derived}`),
|
||||||
|
})
|
||||||
|
: recommendError
|
||||||
|
? t("setupWizard.hwaccel.recommendFailed")
|
||||||
|
: t("setupWizard.hwaccel.autoNone")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{families.map((family) => (
|
||||||
|
<div key={family.key} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={family.key}
|
||||||
|
id={`hwaccel-${family.key}`}
|
||||||
|
className={radioClass(family.key)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`hwaccel-${family.key}`}
|
||||||
|
className="cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
{t(`setupWizard.hwaccel.families.${family.key}`)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={NONE}
|
||||||
|
id="hwaccel-none"
|
||||||
|
className={radioClass(NONE)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="hwaccel-none" className="cursor-pointer text-sm">
|
||||||
|
{t("setupWizard.hwaccel.families.none")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack}>
|
||||||
|
{t("setupWizard.actions.back")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-1 justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={onSkip}>
|
||||||
|
{t("setupWizard.actions.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="select"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? t("setupWizard.actions.saving")
|
||||||
|
: t("setupWizard.actions.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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";
|
||||||
|
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;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SetupRecording({
|
||||||
|
cameraNames,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip,
|
||||||
|
}: 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 });
|
||||||
|
|
||||||
|
const storageInfo = stats?.service?.storage?.["/tmp/frigate/recordings"];
|
||||||
|
const freeGb = storageInfo ? Math.round(storageInfo.free / 1024) : null;
|
||||||
|
const cameraCount = cameraNames.length;
|
||||||
|
// Rough estimate: ~2 Mbps per camera continuous recording
|
||||||
|
const estimatedDays =
|
||||||
|
freeGb && cameraCount > 0
|
||||||
|
? Math.round((freeGb * 1024) / ((2 * 0.125 * 86400) / 1024) / cameraCount)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
requires_restart: 1,
|
||||||
|
});
|
||||||
|
onNext();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("setupWizard.errors.saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [enabled, mode, retentionDays, onNext, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{t("setupWizard.recording.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.recording.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cameraCount === 0 && (
|
||||||
|
<p className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
||||||
|
{t("setupWizard.recording.noCameras")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-md border p-4">
|
||||||
|
<Label htmlFor="recording-toggle" className="font-medium">
|
||||||
|
{t("setupWizard.recording.enableRecording")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="recording-toggle"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="retention-days"
|
||||||
|
type="number"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-6 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button type="button" onClick={onBack}>
|
||||||
|
{t("setupWizard.actions.back")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-1 justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={onSkip}>
|
||||||
|
{t("setupWizard.actions.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="select"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? t("setupWizard.actions.saving")
|
||||||
|
: t("setupWizard.actions.next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Logo from "@/components/Logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type SetupWelcomeProps = {
|
||||||
|
onNext: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SetupWelcome({ onNext, onSkip }: SetupWelcomeProps) {
|
||||||
|
const { t } = useTranslation(["views/setup"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6 py-4">
|
||||||
|
<Logo className="h-16 w-16" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
{t("setupWizard.welcome.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
{t("setupWizard.welcome.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-3 pt-4">
|
||||||
|
<Button variant="select" className="w-full" onClick={onNext}>
|
||||||
|
{t("setupWizard.welcome.getStarted")}
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
{t("setupWizard.welcome.skipSetup")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import StepIndicator from "@/components/indicators/StepIndicator";
|
||||||
|
import SetupCamera from "@/components/setup/SetupCamera";
|
||||||
|
import SetupComplete from "@/components/setup/SetupComplete";
|
||||||
|
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 { dismissSetup } from "@/utils/setupWizard";
|
||||||
|
import { useCallback, useReducer } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LuMoon, LuSun } from "react-icons/lu";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"setupWizard.steps.welcome",
|
||||||
|
"setupWizard.steps.camera",
|
||||||
|
"setupWizard.steps.detector",
|
||||||
|
"setupWizard.steps.hwaccel",
|
||||||
|
"setupWizard.steps.recording",
|
||||||
|
"setupWizard.steps.complete",
|
||||||
|
];
|
||||||
|
|
||||||
|
type WizardState = {
|
||||||
|
currentStep: number;
|
||||||
|
cameraNames: string[];
|
||||||
|
detectorHardwareKey?: string;
|
||||||
|
// 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;
|
||||||
|
hwaccel: boolean;
|
||||||
|
detector: boolean;
|
||||||
|
recording: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type WizardAction =
|
||||||
|
| { type: "NEXT_STEP" }
|
||||||
|
| { type: "PREV_STEP" }
|
||||||
|
| {
|
||||||
|
type: "CAMERAS_ADDED";
|
||||||
|
cameraNames: string[];
|
||||||
|
detectCodecs: Record<string, string>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "STEP_CONFIGURED";
|
||||||
|
step: keyof WizardState["configuredSteps"];
|
||||||
|
savedConfig: boolean;
|
||||||
|
}
|
||||||
|
| { type: "DETECTOR_DONE"; configured: boolean; hardwareKey?: string }
|
||||||
|
| { type: "SKIP_STEP" };
|
||||||
|
|
||||||
|
const initialState: WizardState = {
|
||||||
|
currentStep: 0,
|
||||||
|
cameraNames: [],
|
||||||
|
detectCodecs: {},
|
||||||
|
restartRequired: false,
|
||||||
|
configuredSteps: {
|
||||||
|
camera: false,
|
||||||
|
hwaccel: false,
|
||||||
|
detector: false,
|
||||||
|
recording: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "NEXT_STEP":
|
||||||
|
return { ...state, currentStep: state.currentStep + 1 };
|
||||||
|
case "PREV_STEP":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: Math.max(0, state.currentStep - 1),
|
||||||
|
};
|
||||||
|
case "CAMERAS_ADDED":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: state.currentStep + 1,
|
||||||
|
cameraNames: action.cameraNames,
|
||||||
|
detectCodecs: action.detectCodecs,
|
||||||
|
configuredSteps: { ...state.configuredSteps, camera: true },
|
||||||
|
};
|
||||||
|
case "STEP_CONFIGURED":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: state.currentStep + 1,
|
||||||
|
restartRequired: state.restartRequired || action.savedConfig,
|
||||||
|
configuredSteps: { ...state.configuredSteps, [action.step]: true },
|
||||||
|
};
|
||||||
|
case "DETECTOR_DONE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentStep: state.currentStep + 1,
|
||||||
|
detectorHardwareKey: action.hardwareKey ?? state.detectorHardwareKey,
|
||||||
|
restartRequired: state.restartRequired || action.configured,
|
||||||
|
configuredSteps: {
|
||||||
|
...state.configuredSteps,
|
||||||
|
detector: state.configuredSteps.detector || action.configured,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SKIP_STEP":
|
||||||
|
return { ...state, currentStep: state.currentStep + 1 };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SetupWizard() {
|
||||||
|
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(() => {
|
||||||
|
dismissSetup();
|
||||||
|
window.location.href = window.baseUrl || "/";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDetectorNext = useCallback((hardwareKey: string) => {
|
||||||
|
dispatch({ type: "DETECTOR_DONE", configured: true, hardwareKey });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDetectorSkip = useCallback((hardwareKey?: string) => {
|
||||||
|
dispatch({ type: "DETECTOR_DONE", configured: false, hardwareKey });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRecordingNext = useCallback(() => {
|
||||||
|
dispatch({ type: "STEP_CONFIGURED", step: "recording", savedConfig: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
dispatch({ type: "PREV_STEP" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkipStep = useCallback(() => {
|
||||||
|
dispatch({ type: "SKIP_STEP" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (state.currentStep) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<SetupWelcome
|
||||||
|
onNext={() => dispatch({ type: "NEXT_STEP" })}
|
||||||
|
onSkip={handleSkipSetup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return <SetupCamera onNext={handleCameraNext} onBack={handleBack} />;
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<SetupDetector
|
||||||
|
cameraCount={state.cameraNames.length}
|
||||||
|
onNext={handleDetectorNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSkip={handleDetectorSkip}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<SetupHwAccel
|
||||||
|
detectorHardwareKey={state.detectorHardwareKey}
|
||||||
|
detectCodecs={state.detectCodecs}
|
||||||
|
onNext={handleHwAccelNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSkip={handleSkipStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<SetupRecording
|
||||||
|
cameraNames={state.cameraNames}
|
||||||
|
onNext={handleRecordingNext}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSkip={handleSkipStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<SetupComplete
|
||||||
|
cameraNames={state.cameraNames}
|
||||||
|
configuredSteps={state.configuredSteps}
|
||||||
|
restartRequired={state.restartRequired}
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
steps={STEPS}
|
||||||
|
currentStep={state.currentStep}
|
||||||
|
variant="dots"
|
||||||
|
translationNameSpace="views/setup"
|
||||||
|
className="mb-4 justify-start"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fade-in">{renderStep()}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,3 +11,14 @@ export type DetectionHardware = {
|
|||||||
count: number;
|
count: number;
|
||||||
unlimited: boolean;
|
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 = {
|
||||||
|
recommended: string;
|
||||||
|
available: HwaccelFamily[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// dismissing the setup wizard is per-device UI state, so it lives in the
|
||||||
|
// browser rather than in the config the wizard exists to write
|
||||||
|
const DISMISSED_KEY = "frigate-setup-dismissed";
|
||||||
|
|
||||||
|
export function isSetupDismissed(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DISMISSED_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissSetup(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, "true");
|
||||||
|
} catch {
|
||||||
|
// storage can be unavailable; showing the wizard again beats failing here
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user