mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 06:42:51 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f25d785380 | ||
|
|
ff71e823c8 | ||
|
|
1f9ed9eaaa | ||
|
|
5670d05aa6 | ||
|
|
b8cfe68d26 | ||
|
|
d02811d2b4 | ||
|
|
bb5e9264b0 |
Vendored
+89
@@ -4053,6 +4053,58 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/hardware/hwaccel:
|
||||
get:
|
||||
tags:
|
||||
- Hardware
|
||||
summary: Hwaccel Recommendation
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the hardware decoding this system can do.
|
||||
|
||||
Args:
|
||||
detector: Hardware key of the detection hardware in use, which biases
|
||||
the recommendation toward that hardware's GPU
|
||||
codecs: Comma separated codecs of the streams that will be decoded,
|
||||
used to drop families that cannot decode one of them
|
||||
|
||||
Returns:
|
||||
The recommended family (empty when none fits) and every usable family
|
||||
operationId: hwaccel_recommendation_hardware_hwaccel_get
|
||||
parameters:
|
||||
- name: detector
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Detector
|
||||
- name: codecs
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Codecs
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HwaccelRecommendation'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/events:
|
||||
get:
|
||||
tags:
|
||||
@@ -8670,6 +8722,43 @@ components:
|
||||
- label
|
||||
title: HardwareUnit
|
||||
description: One physical piece of hardware.
|
||||
HwaccelFamily:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
title: Family key
|
||||
description: Stable identifier for this kind of hardware decoding.
|
||||
presets:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
title: Presets
|
||||
description: The ffmpeg preset for each codec this family decodes, or
|
||||
a single 'any' preset when it decodes every codec.
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- presets
|
||||
title: HwaccelFamily
|
||||
description: A kind of hardware decoding, and the presets that drive it.
|
||||
HwaccelRecommendation:
|
||||
properties:
|
||||
recommended:
|
||||
type: string
|
||||
title: Recommended family
|
||||
description: Key of the family that fits this system best, or an empty
|
||||
string when none does.
|
||||
available:
|
||||
items:
|
||||
$ref: '#/components/schemas/HwaccelFamily'
|
||||
type: array
|
||||
title: Available families
|
||||
description: Every family this system's hardware can use, best first.
|
||||
type: object
|
||||
required:
|
||||
- recommended
|
||||
title: HwaccelRecommendation
|
||||
description: The hardware decoding this system can do.
|
||||
Last24HoursReview:
|
||||
properties:
|
||||
reviewed_alert:
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.detectors.hardware import DetectionHardware, hardware_prober
|
||||
from frigate.util.hwaccel import HwaccelRecommendation, hwaccel_options
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,3 +29,29 @@ def probe_hardware(refresh: bool = False) -> list[DetectionHardware]:
|
||||
Every kind of detection hardware that was found
|
||||
"""
|
||||
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:
|
||||
"""Return the path of the config file to use.
|
||||
|
||||
Both .yml and .yaml are supported, so fall back to the other extension when
|
||||
the configured path does not exist. If neither exists the configured path is
|
||||
returned so a new config is created with the default .yml extension.
|
||||
"""
|
||||
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
||||
|
||||
if not os.path.isfile(config_path):
|
||||
config_path = config_path.replace("yml", "yaml")
|
||||
base, ext = os.path.splitext(config_path)
|
||||
alternate = f"{base}.yaml" if ext == ".yml" else f"{base}.yml"
|
||||
|
||||
if os.path.isfile(alternate):
|
||||
return alternate
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
@@ -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;
|
||||
configSchema?: Record<string, unknown>;
|
||||
hardware?: unknown[];
|
||||
hwaccel?: {
|
||||
recommended: string;
|
||||
available?: { key: string; presets: Record<string, string> }[];
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiMocker {
|
||||
@@ -185,6 +189,17 @@ export class ApiMocker {
|
||||
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
|
||||
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
||||
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 Statusbar from "./components/Statusbar";
|
||||
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 { cn } from "./lib/utils";
|
||||
import { isPWA } from "./utils/isPWA";
|
||||
@@ -15,6 +15,7 @@ import useSWR from "swr";
|
||||
import { FrigateConfig } from "./types/frigateConfig";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { isRedirectingToLogin } from "@/api/auth-redirect";
|
||||
import { isSetupDismissed } from "@/utils/setupWizard";
|
||||
|
||||
const Live = lazy(() => import("@/pages/Live"));
|
||||
const Events = lazy(() => import("@/pages/Events"));
|
||||
@@ -30,6 +31,7 @@ const Chat = lazy(() => import("@/pages/Chat"));
|
||||
const Logs = lazy(() => import("@/pages/Logs"));
|
||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||
const Replay = lazy(() => import("@/pages/Replay"));
|
||||
const SetupWizard = lazy(() => import("@/pages/SetupWizard"));
|
||||
|
||||
function App() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@@ -52,6 +54,18 @@ function DefaultAppView() {
|
||||
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
|
||||
// to prevent race condition where custom roles are temporarily unavailable
|
||||
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 (
|
||||
<div className="size-full overflow-hidden">
|
||||
{isDesktop && <Sidebar />}
|
||||
|
||||
@@ -74,11 +74,14 @@ const STEPS = [
|
||||
type CameraWizardDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
// lets callers reuse what was probed here instead of probing again
|
||||
onCameraAdded?: (camera: { name: string; detectCodec?: string }) => void;
|
||||
};
|
||||
|
||||
export default function CameraWizardDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCameraAdded,
|
||||
}: CameraWizardDialogProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { mutate: updateConfig } = useSWR("config");
|
||||
@@ -271,6 +274,13 @@ export default function CameraWizardDialog({
|
||||
.put("config/set", requestBody)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
onCameraAdded?.({
|
||||
name: finalCameraName,
|
||||
detectCodec: wizardData.streams?.find((stream) =>
|
||||
stream.roles.includes("detect"),
|
||||
)?.testResult?.videoCodec,
|
||||
});
|
||||
|
||||
// Configure go2rtc streams for all streams
|
||||
if (wizardData.streams && wizardData.streams.length > 0) {
|
||||
const go2rtcStreams: Record<string, string[]> = {};
|
||||
@@ -393,7 +403,7 @@ export default function CameraWizardDialog({
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[updateConfig, t, onClose],
|
||||
[updateConfig, t, onClose, onCameraAdded],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
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