Compare commits

...

5 Commits

Author SHA1 Message Date
Josh Hawkins
69fa13f6ce
Merge b5a360be39 into a7df17cc61 2026-06-17 01:18:14 +08:00
Josh Hawkins
a7df17cc61
update ffmpeg navpath title (#23494) 2026-06-16 09:59:25 -06:00
Josh Hawkins
c79ca9838f
UI tweaks (#23492)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* slightly darken bg-card

* change menu label

* move snapshot retain out of advanced fields

* add new ui options for collapsibles

* backend title and description

* remove unused snapshot retention field

* update reference config

* remove further references to snapshots retain.mode
2026-06-16 08:56:52 -05:00
Josh Hawkins
b5a360be39 add test 2026-04-17 17:18:11 -05:00
Josh Hawkins
54a7c5015e fix birdseye layout calculation
replace the two pass layout with a single pass pixel space algorithm
2026-04-17 17:18:04 -05:00
20 changed files with 355 additions and 219 deletions

View File

@ -655,11 +655,6 @@ snapshots:
retain:
# Required: Default retention days (default: shown below)
default: 10
# Optional: Mode for retention. (default: shown below)
# all - save all snapshots regardless of activity
# motion - save snapshots for any detected motion
# active_objects - save snapshots for active/moving objects
mode: motion
# Optional: Per object retention days
objects:
person: 15

View File

@ -54,7 +54,7 @@ The ffmpeg process for capturing audio will be a separate connection to the came
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add an input with the `audio` role pointing to a stream that includes audio.
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add an input with the `audio` role pointing to a stream that includes audio.
</TabItem>
<TabItem value="yaml">

View File

@ -24,7 +24,7 @@ Each role can only be assigned to one input per camera. The options for roles ar
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
| Field | Description |
| ----------------- | ------------------------------------------------------------------- |

View File

@ -33,7 +33,7 @@ Select the appropriate hwaccel preset for your hardware.
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to the appropriate preset for your hardware.
2. To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and set **Hardware acceleration arguments** for that camera.
2. To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and set **Hardware acceleration arguments** for that camera.
</TabItem>
<TabItem value="yaml">

View File

@ -85,7 +85,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -105,7 +105,7 @@ ffmpeg:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.264)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -123,7 +123,7 @@ ffmpeg:
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Intel QuickSync (H.265)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -178,7 +178,7 @@ VAAPI supports automatic profile selection so it will work automatically with bo
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `VAAPI (Intel/AMD GPU)`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -237,7 +237,7 @@ Using `preset-nvidia` ffmpeg will automatically select the necessary profile for
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA GPU`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -300,7 +300,7 @@ If you are using the HA App, you may need to use the full access variant and tur
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Raspberry Pi (H.264)` (for H.264 streams) or `Raspberry Pi (H.265)` (for H.265/HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -420,7 +420,7 @@ For example, for H264 video, you'll select `preset-jetson-h264`.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `NVIDIA Jetson (H.264)` (or `NVIDIA Jetson (H.265)` for HEVC streams). For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -452,7 +452,7 @@ Set the FFmpeg hwaccel preset to enable hardware video processing.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and set **Hardware acceleration arguments** to `Rockchip RKMPP`. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">
@ -519,7 +519,7 @@ Set the FFmpeg hwaccel args to enable hardware video processing.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />.
Navigate to <NavPath path="Settings > Global configuration > FFmpeg" /> and configure the hardware acceleration args and input args manually for Synaptics hardware. For per-camera overrides, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />.
</TabItem>
<TabItem value="yaml">

View File

@ -363,7 +363,7 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available).
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add your camera streams.
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
@ -475,7 +475,7 @@ Navigate to <NavPath path="Settings > Camera configuration > License plate recog
| **Enable LPR** | Set to on |
| **Enhancement level** | Set to `3` (optional — enhances the image before trying to recognize characters) |
Navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> and add your camera streams.
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.

View File

@ -61,7 +61,7 @@ Configure the go2rtc stream and point the camera inputs at the local restream.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and set the input paths to use the local restream URL (`rtsp://127.0.0.1:8554/<camera_name>`).
</TabItem>
<TabItem value="yaml">
@ -111,7 +111,7 @@ Two connections are made to the camera. One for the sub stream, one for the rest
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > FFmpeg" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
Navigate to <NavPath path="Settings > System > go2rtc streams" /> and add stream entries for each camera and its sub stream. Then navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> for each camera and configure separate inputs for the main and sub streams using the local restream URLs.
</TabItem>
<TabItem value="yaml">

View File

@ -111,7 +111,6 @@ Navigate to <NavPath path="Settings > Global configuration > Snapshots" />.
| Field | Description |
| -------------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Snapshot retention > Default retention** | Number of days to retain snapshots (default: 10) |
| **Snapshot retention > Retention mode** | Retention mode: `all`, `motion`, or `active_objects` |
| **Snapshot retention > Object retention > Person** | Per-object overrides for retention days (e.g., keep `person` snapshots for 15 days) |
</TabItem>
@ -122,7 +121,6 @@ snapshots:
enabled: True
retain:
default: 10
mode: motion
objects:
person: 15
```

View File

@ -348,7 +348,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
<ConfigTabs>
<TabItem value="ui">
1. If you have separate streams for detect and record, navigate to <NavPath path="Settings > Camera configuration > FFmpeg" />, select your camera, and add a second input with the `record` role pointing to your high-resolution stream
1. If you have separate streams for detect and record, navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" />, select your camera, and add a second input with the `record` role pointing to your high-resolution stream
2. Navigate to <NavPath path="Settings > Global configuration > Recording" /> (or <NavPath path="Settings > Camera configuration > Recording" /> for a specific camera) and set **Enable recording** to on
</TabItem>

View File

@ -100,8 +100,8 @@ class CameraConfig(FrigateBaseModel):
description="Settings for face detection and recognition for this camera.",
)
ffmpeg: CameraFfmpegConfig = Field(
title="FFmpeg",
description="FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
title="Streams (FFmpeg)",
description="Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.",
)
live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig,

View File

@ -3,7 +3,6 @@ from typing import Optional
from pydantic import Field
from ..base import FrigateBaseModel
from .record import RetainModeEnum
__all__ = ["SnapshotsConfig", "RetainConfig"]
@ -14,11 +13,6 @@ class RetainConfig(FrigateBaseModel):
title="Default retention",
description="Default number of days to retain snapshots.",
)
mode: RetainModeEnum = Field(
default=RetainModeEnum.motion,
title="Retention mode",
description="Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects).",
)
objects: dict[str, float] = Field(
default_factory=dict,
title="Object retention",

View File

@ -595,112 +595,92 @@ class BirdsEyeFrameManager:
) -> Optional[list[list[Any]]]:
"""Calculate the optimal layout for 2+ cameras."""
def map_layout(
camera_layout: list[list[Any]], row_height: int
) -> tuple[int, int, Optional[list[list[Any]]]]:
"""Map the calculated layout."""
candidate_layout = []
starting_x = 0
x = 0
max_width = 0
y = 0
def find_available_x(
current_x: int,
width: int,
reserved_ranges: list[tuple[int, int]],
max_width: int,
) -> Optional[int]:
"""Find the first horizontal slot that does not collide with reservations."""
x = current_x
for row in camera_layout:
final_row = []
max_width = max(max_width, x)
x = starting_x
for cameras in row:
camera_dims = self.cameras[cameras[0]]["dimensions"].copy()
camera_aspect = cameras[1]
for reserved_start, reserved_end in sorted(reserved_ranges):
if x >= reserved_end:
continue
if camera_dims[1] > camera_dims[0]:
scaled_height = int(row_height * 2)
scaled_width = int(scaled_height * camera_aspect)
starting_x = scaled_width
else:
scaled_height = row_height
scaled_width = int(scaled_height * camera_aspect)
if x + width <= reserved_start:
return x
# layout is too large
if (
x + scaled_width > self.canvas.width
or y + scaled_height > self.canvas.height
):
return x + scaled_width, y + scaled_height, None
x = max(x, reserved_end)
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
x += scaled_width
if x + width <= max_width:
return x
y += row_height
candidate_layout.append(final_row)
if max_width == 0:
max_width = x
return max_width, y, candidate_layout
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
camera_layout: list[list[Any]] = []
camera_layout.append([])
starting_x = 0
x = starting_x
y = 0
y_i = 0
max_y = 0
for camera in cameras_to_add:
camera_dims = self.cameras[camera]["dimensions"].copy()
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
camera, camera_dims[0], camera_dims[1]
)
if camera_dims[1] > camera_dims[0]:
portrait = True
else:
portrait = False
if (x + camera_aspect_x) <= canvas_aspect_x:
# insert if camera can fit on current row
camera_layout[y_i].append(
(
camera,
camera_aspect_x / camera_aspect_y,
)
)
if portrait:
starting_x = camera_aspect_x
else:
max_y = max(
max_y,
camera_aspect_y,
)
x += camera_aspect_x
else:
# move on to the next row and insert
y += max_y
y_i += 1
camera_layout.append([])
x = starting_x
if x + camera_aspect_x > canvas_aspect_x:
return None
camera_layout[y_i].append(
(
camera,
camera_aspect_x / camera_aspect_y,
)
)
x += camera_aspect_x
if y + max_y > canvas_aspect_y:
return None
row_height = int(self.canvas.height / coefficient)
total_width, total_height, standard_candidate_layout = map_layout(
camera_layout, row_height
)
def map_layout(row_height: int) -> tuple[int, int, Optional[list[list[Any]]]]:
"""Lay out cameras row by row while reserving portrait spans for the next row."""
candidate_layout: list[list[Any]] = []
reserved_ranges: dict[int, list[tuple[int, int]]] = {}
current_row: list[Any] = []
row_index = 0
row_y = 0
row_x = 0
max_width = 0
max_height = 0
for camera in cameras_to_add:
camera_dims = self.cameras[camera]["dimensions"].copy()
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
camera, camera_dims[0], camera_dims[1]
)
portrait = camera_dims[1] > camera_dims[0]
scaled_height = row_height * 2 if portrait else row_height
scaled_width = int(scaled_height * (camera_aspect_x / camera_aspect_y))
while True:
x = find_available_x(
row_x,
scaled_width,
reserved_ranges.get(row_index, []),
self.canvas.width,
)
if x is not None and row_y + scaled_height <= self.canvas.height:
current_row.append(
(camera, (x, row_y, scaled_width, scaled_height))
)
row_x = x + scaled_width
max_width = max(max_width, row_x)
max_height = max(max_height, row_y + scaled_height)
if portrait:
reserved_ranges.setdefault(row_index + 1, []).append(
(x, row_x)
)
break
if current_row:
candidate_layout.append(current_row)
current_row = []
row_index += 1
row_y = row_index * row_height
row_x = 0
if row_y + scaled_height > self.canvas.height:
overflow_width = max(max_width, scaled_width)
overflow_height = row_y + scaled_height
return overflow_width, overflow_height, None
if current_row:
candidate_layout.append(current_row)
return max_width, max_height, candidate_layout
row_height = max(1, int(self.canvas.height / coefficient))
total_width, total_height, standard_candidate_layout = map_layout(row_height)
if not standard_candidate_layout:
# if standard layout didn't work
@ -709,9 +689,9 @@ class BirdsEyeFrameManager:
total_width / self.canvas.width,
total_height / self.canvas.height,
)
row_height = int(row_height / scale_down_percent)
row_height = max(1, int(row_height / scale_down_percent))
total_width, total_height, standard_candidate_layout = map_layout(
camera_layout, row_height
row_height
)
if not standard_candidate_layout:
@ -725,8 +705,8 @@ class BirdsEyeFrameManager:
1 / (total_width / self.canvas.width),
1 / (total_height / self.canvas.height),
)
row_height = int(row_height * scale_up_percent)
_, _, scaled_layout = map_layout(camera_layout, row_height)
row_height = max(1, int(row_height * scale_up_percent))
_, _, scaled_layout = map_layout(row_height)
if scaled_layout:
return scaled_layout

View File

@ -1,11 +1,64 @@
"""Test camera user and password cleanup."""
"""Tests for Birdseye canvas sizing and layout behavior."""
import unittest
from multiprocessing import Event
from frigate.output.birdseye import get_canvas_shape
from frigate.config import FrigateConfig
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
class TestBirdseye(unittest.TestCase):
def _build_manager(
self, camera_dimensions: dict[str, tuple[int, int]]
) -> BirdsEyeFrameManager:
config = {
"mqtt": {"host": "mqtt"},
"birdseye": {"width": 1280, "height": 720},
"cameras": {},
}
for order, (camera, dimensions) in enumerate(
camera_dimensions.items(), start=1
):
config["cameras"][camera] = {
"ffmpeg": {
"inputs": [
{
"path": f"rtsp://10.0.0.1:554/{camera}",
"roles": ["detect"],
}
]
},
"detect": {
"width": dimensions[0],
"height": dimensions[1],
"fps": 5,
},
"birdseye": {"order": order},
}
return BirdsEyeFrameManager(FrigateConfig(**config), Event())
def _assert_no_overlaps(
self, layout: list[list[tuple[str, tuple[int, int, int, int]]]]
):
rectangles = [position for row in layout for _, position in row]
for index, rect in enumerate(rectangles):
x1, y1, width1, height1 = rect
for other in rectangles[index + 1 :]:
x2, y2, width2, height2 = other
overlap = (
x1 < x2 + width2
and x2 < x1 + width1
and y1 < y2 + height2
and y2 < y1 + height1
)
self.assertFalse(
overlap,
msg=f"Overlapping rectangles found: {rect} and {other}",
)
def test_16x9(self):
"""Test 16x9 aspect ratio works as expected for birdseye."""
width = 1280
@ -45,3 +98,104 @@ class TestBirdseye(unittest.TestCase):
canvas_width, canvas_height = get_canvas_shape(width, height)
assert canvas_width == width # width will be the same
assert canvas_height != height
def test_portrait_camera_does_not_overlap_next_row(self):
"""Portrait cameras should reserve their real horizontal position on the next row."""
manager = self._build_manager(
{
"cam_a": (1280, 720),
"cam_p": (360, 640),
"cam_b": (1280, 720),
"cam_c": (640, 480),
}
)
layout = manager.calculate_layout(["cam_a", "cam_p", "cam_b", "cam_c"], 3)
self.assertIsNotNone(layout)
assert layout is not None
self._assert_no_overlaps(layout)
cam_c = [
position for row in layout for camera, position in row if camera == "cam_c"
][0]
self.assertEqual(cam_c[0], 0)
def test_portrait_reservation_only_applies_to_next_row(self):
"""Portrait reservations should not push later rows after the span ends."""
manager = self._build_manager(
{
"cam_a": (1280, 720),
"cam_p": (360, 640),
"cam_b": (1280, 720),
"cam_c": (1280, 720),
"cam_d": (1280, 720),
"cam_e": (1280, 720),
}
)
layout = manager.calculate_layout(
["cam_a", "cam_p", "cam_b", "cam_c", "cam_d", "cam_e"],
3,
)
self.assertIsNotNone(layout)
assert layout is not None
self._assert_no_overlaps(layout)
cam_e = [
position for row in layout for camera, position in row if camera == "cam_e"
][0]
self.assertEqual(cam_e[0], 0)
def test_multiple_portraits_reserve_distinct_ranges(self):
"""Multiple portrait cameras in one row should reserve separate spans below them."""
manager = self._build_manager(
{
"cam_a": (640, 480),
"cam_p1": (360, 640),
"cam_p2": (360, 640),
"cam_b": (640, 480),
"cam_c": (1280, 720),
"cam_d": (640, 480),
}
)
layout = manager.calculate_layout(
["cam_a", "cam_p1", "cam_p2", "cam_b", "cam_c", "cam_d"],
4,
)
self.assertIsNotNone(layout)
assert layout is not None
self._assert_no_overlaps(layout)
def test_two_landscapes_then_portrait_then_two_landscapes(self):
"""A portrait after two landscapes should reserve only its own tail span."""
manager = self._build_manager(
{
"cam_a": (1280, 720),
"cam_b": (1280, 720),
"cam_p": (360, 640),
"cam_c": (1280, 720),
"cam_d": (1280, 720),
}
)
layout = manager.calculate_layout(
["cam_a", "cam_b", "cam_p", "cam_c", "cam_d"],
3,
)
self.assertIsNotNone(layout)
assert layout is not None
self._assert_no_overlaps(layout)
cam_c = [
position for row in layout for camera, position in row if camera == "cam_c"
][0]
cam_d = [
position for row in layout for camera, position in row if camera == "cam_d"
][0]
self.assertEqual(cam_c[0], 0)
self.assertEqual(cam_d[0], cam_c[0] + cam_c[2])

View File

@ -152,8 +152,8 @@
}
},
"ffmpeg": {
"label": "FFmpeg",
"description": "FFmpeg settings including binary path, args, hwaccel options, and per-role output args.",
"label": "Streams (FFmpeg)",
"description": "Camera stream inputs and FFmpeg options, including binary path, args, hwaccel, and per-role output args.",
"path": {
"label": "FFmpeg path",
"description": "Path to the FFmpeg binary to use or a version alias (\"7.0\" or \"8.0\")."
@ -666,10 +666,6 @@
"label": "Default retention",
"description": "Default number of days to retain snapshots."
},
"mode": {
"label": "Retention mode",
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
},
"objects": {
"label": "Object retention",
"description": "Per-object overrides for snapshot retention days."

View File

@ -1176,10 +1176,6 @@
"label": "Default retention",
"description": "Default number of days to retain snapshots."
},
"mode": {
"label": "Retention mode",
"description": "Mode for retention: all (save all segments), motion (save segments with motion), or active_objects (save segments with active objects)."
},
"objects": {
"label": "Object retention",
"description": "Per-object overrides for snapshot retention days."

View File

@ -85,7 +85,7 @@
"integrationObjectClassification": "Object classification",
"integrationAudioTranscription": "Audio transcription",
"cameraDetect": "Object detection",
"cameraFfmpeg": "FFmpeg",
"cameraFfmpeg": "Streams (FFmpeg)",
"cameraRecording": "Recording",
"cameraSnapshots": "Snapshots",
"cameraMotion": "Motion detection",

View File

@ -44,7 +44,14 @@ const record: SectionConfigOverrides = {
hiddenFields: ["enabled_in_config", "sync_recordings"],
advancedFields: ["expire_interval", "preview", "export"],
uiSchema: {
continuous: {
"ui:options": { defaultOpen: true, disableCollapsible: true },
},
motion: {
"ui:options": { defaultOpen: true, disableCollapsible: true },
},
export: {
"ui:options": { defaultOpen: true, disableCollapsible: true },
hwaccel_args: {
"ui:widget": "FfmpegArgsWidget",
"ui:options": {
@ -59,9 +66,12 @@ const record: SectionConfigOverrides = {
"detections.retain.mode": {
"ui:options": { enumI18nPrefix: "retainMode" },
},
"preview.quality": {
"ui:options": {
enumI18nPrefix: "previewQuality",
preview: {
"ui:options": { defaultOpen: true, disableCollapsible: true },
quality: {
"ui:options": {
enumI18nPrefix: "previewQuality",
},
},
},
},

View File

@ -21,13 +21,14 @@ const snapshots: SectionConfigOverrides = {
"crop",
"quality",
"timestamp",
"required_zones",
"retain",
],
fieldGroups: {
display: ["bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["height", "quality", "retain"],
advancedFields: ["height", "quality"],
uiSchema: {
required_zones: {
"ui:widget": "zoneNames",
@ -35,11 +36,6 @@ const snapshots: SectionConfigOverrides = {
suppressMultiSchema: true,
},
},
"retain.mode": {
"ui:options": {
enumI18nPrefix: "retainMode",
},
},
},
},
global: {

View File

@ -156,7 +156,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
};
const hasModifiedDescendants = checkSubtreeModified(fieldPath);
const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
const defaultOpen = uiSchema?.["ui:options"]?.defaultOpen === true;
const [isOpen, setIsOpen] = useState(hasModifiedDescendants || defaultOpen);
const resetKey = `${formContext?.level ?? "global"}::${
formContext?.cameraName ?? "global"
}`;
@ -192,6 +193,8 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
const disableNestedCard =
uiSchema?.["ui:options"]?.disableNestedCard === true;
const disableCollapsible =
uiSchema?.["ui:options"]?.disableCollapsible === true;
const isHiddenProp = (prop: (typeof properties)[number]) =>
(prop.content.props as RjsfElementProps).uiSchema?.["ui:widget"] ===
@ -228,10 +231,10 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
useEffect(() => {
if (lastResetKeyRef.current !== resetKey) {
lastResetKeyRef.current = resetKey;
setIsOpen(hasModifiedDescendants);
setIsOpen(hasModifiedDescendants || defaultOpen);
setShowAdvanced(hasModifiedAdvanced);
}
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]);
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced, defaultOpen]);
const { children } = props as ObjectFieldTemplateProps & {
children?: ReactNode;
};
@ -458,6 +461,75 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
);
}
// Label/description/docs header shared by the collapsible and static layouts.
const cardHeaderContent = (
<div className="min-w-0 pr-3">
<CardTitle
className={cn(
"flex items-center text-sm",
hasModifiedDescendants && "text-unsaved",
)}
>
{inferredLabel}
{objectRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{inferredDescription}
</p>
)}
{fieldDocsUrl && (
<div className="mt-1 flex items-center text-xs text-primary-variant">
<Link
to={fieldDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
onClick={(e) => e.stopPropagation()}
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</div>
);
// Body shared by the collapsible and static layouts.
const cardBody = hasCustomChildren ? (
children
) : (
<>
{renderGroupedFields(regularProps)}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{renderGroupedFields(advancedProps)}
</AdvancedCollapsible>
</>
);
// Static (non-collapsible) card: keep the labeled header, always show content.
if (disableCollapsible) {
return (
<Card className="w-full">
<CardHeader className="p-4">{cardHeaderContent}</CardHeader>
<CardContent className="space-y-6 p-4 pt-0">{cardBody}</CardContent>
</Card>
);
}
// Nested objects render as collapsible cards
return (
<Card className="w-full">
@ -465,38 +537,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div className="min-w-0 pr-3">
<CardTitle
className={cn(
"flex items-center text-sm",
hasModifiedDescendants && "text-unsaved",
)}
>
{inferredLabel}
{objectRequiresRestart && (
<RestartRequiredIndicator className="ml-2" />
)}
</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{inferredDescription}
</p>
)}
{fieldDocsUrl && (
<div className="mt-1 flex items-center text-xs text-primary-variant">
<Link
to={fieldDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
onClick={(e) => e.stopPropagation()}
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</div>
{cardHeaderContent}
{isOpen ? (
<LuChevronDown className="h-4 w-4 shrink-0" />
) : (
@ -506,31 +547,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-6 p-4 pt-0">
{hasCustomChildren ? (
children
) : (
<>
{renderGroupedFields(regularProps)}
<AddPropertyButton
onAddProperty={onAddProperty}
schema={schema}
uiSchema={uiSchema}
formData={formData}
disabled={disabled}
readonly={readonly}
/>
<AdvancedCollapsible
count={advancedProps.length}
open={showAdvanced}
onOpenChange={setShowAdvanced}
>
{renderGroupedFields(advancedProps)}
</AdvancedCollapsible>
</>
)}
</CardContent>
<CardContent className="space-y-6 p-4 pt-0">{cardBody}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>

View File

@ -113,8 +113,8 @@
--foreground: hsl(0, 0%, 100%);
--foreground: 0, 0%, 100%;
--card: hsl(0, 0%, 15%);
--card: 0, 0%, 15%;
--card: hsl(0, 0%, 12%);
--card: 0, 0%, 12%;
--card-foreground: hsl(210, 40%, 98%);
--card-foreground: 210 40% 98%;