Merge branch 'dev' into dev

This commit is contained in:
GuoQing Liu 2025-03-07 23:05:38 +08:00 committed by GitHub
commit 5bc26b626b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1547 additions and 1538 deletions

View File

@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- uses: actions/setup-node@master
with:
node-version: 16.x
node-version: 20.x
- name: Install devcontainer cli
run: npm install --global @devcontainers/cli
- name: Build devcontainer
@ -64,6 +64,9 @@ jobs:
node-version: 20.x
- run: npm install
working-directory: ./web
- name: Build web
run: npm run build
working-directory: ./web
# - name: Test
# run: npm run test
# working-directory: ./web
@ -77,7 +80,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.3.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install requirements
@ -99,14 +102,6 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@master
with:
node-version: 16.x
- run: npm install
working-directory: ./web
- name: Build web
run: npm run build
working-directory: ./web
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx

View File

@ -1,7 +1,7 @@
aiofiles == 24.1.*
click == 8.1.*
# FastAPI
aiohttp == 3.11.2
aiohttp == 3.11.3
starlette == 0.41.2
starlette-context == 0.3.6
fastapi == 0.115.*
@ -20,9 +20,9 @@ pandas == 2.2.*
peewee == 3.17.*
peewee_migrate == 1.13.*
psutil == 6.1.*
pydantic == 2.8.*
pydantic == 2.10.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2024.*
pytz == 2025.*
pyzmq == 26.2.*
ruamel.yaml == 0.18.*
tzlocal == 5.2
@ -34,8 +34,8 @@ ws4py == 0.5.*
unidecode == 1.3.*
# Image Manipulation
numpy == 1.26.*
opencv-python-headless == 4.10.0.*
opencv-contrib-python == 4.9.0.*
opencv-python-headless == 4.11.0.*
opencv-contrib-python == 4.11.0.*
scipy == 1.14.*
# OpenVino & ONNX
openvino == 2024.4.*
@ -46,7 +46,7 @@ transformers == 4.45.*
# Generative AI
google-generativeai == 0.8.*
ollama == 0.3.*
openai == 1.51.*
openai == 1.65.*
# push notifications
py-vapid == 1.9.*
pywebpush == 2.0.*

View File

@ -7,7 +7,7 @@ title: Camera Configuration
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
A camera is enabled by default but can be temporarily disabled by using `enabled: False`. Existing tracked objects and recordings can still be accessed. Live streams, recording and detecting are not working. Camera specific configurations will be used.
A camera is enabled by default but can be disabled by using `enabled: False`. Cameras that are disabled through the configuration file will not appear in the Frigate UI and will not consume system resources.
Each role can only be assigned to one input per camera. The options for roles are as follows:

View File

@ -5,7 +5,7 @@ title: Generative AI
Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail.
Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle. Descriptions can also be regenerated manually via the Frigate UI.
Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response.
## Configuration
@ -148,6 +148,15 @@ While generating simple descriptions of detected objects is useful, understandin
Frigate provides an [MQTT topic](/integrations/mqtt), `frigate/tracked_object_update`, that is updated with a JSON payload containing `event_id` and `description` when your AI provider returns a description for a tracked object. This description could be used directly in notifications, such as sending alerts to your phone or making audio announcements. If additional details from the tracked object are needed, you can query the [HTTP API](/integrations/api/event-events-event-id-get) using the `event_id`, eg: `http://frigate_ip:5000/api/events/<event_id>`.
If looking to get notifications earlier than when an object ceases to be tracked, an additional send trigger can be configured of `after_significant_updates`.
```yaml
genai:
send_triggers:
tracked_object_end: true # default
after_significant_updates: 3 # how many updates to a tracked object before we should send an image
```
## Custom Prompts
Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows:

View File

@ -115,7 +115,7 @@ lpr:
Ensure that:
- Your camera has a clear, well-lit view of the plate.
- Your camera has a clear, human-readable, well-lit view of the plate. If you can't read the plate, Frigate certainly won't be able to. This may require changing video size, quality, or frame rate settings on your camera, depending on your scene and how fast the vehicles are traveling.
- The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream.
- A `car` is detected first, as LPR only runs on recognized vehicles.

View File

@ -183,32 +183,46 @@ The default dashboard ("All Cameras") will always use Smart Streaming and the fi
:::
### Disabling cameras
Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI.
For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily.
## Live view FAQ
1. Why don't I have audio in my Live view?
1. **Why don't I have audio in my Live view?**
You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc.
Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc.
2. Frigate shows that my live stream is in "low bandwidth mode". What does this mean?
2. **Frigate shows that my live stream is in "low bandwidth mode". What does this mean?**
Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible.
When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. You can also try using the _Reset_ button to force a reload of your stream.
If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the recommendations above or ensure you have enough bandwidth available.
3. It doesn't seem like my cameras are streaming on the Live dashboard. Why?
3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?**
On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group.
4. I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?
4. **I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?**
This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line.
5. How does "smart streaming" work?
5. **How does "smart streaming" work?**
Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream.
This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats.
This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras.
6. I have unmuted some cameras on my dashboard, but I do not hear sound. Why?
6. **I have unmuted some cameras on my dashboard, but I do not hear sound. Why?**
If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior.

View File

@ -230,7 +230,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
#### YOLOv9
[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default.
[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default.
:::tip
@ -513,7 +513,7 @@ model:
#### YOLOv9
[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default.
[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default.
:::tip

View File

@ -183,6 +183,8 @@ record:
sync_recordings: True
```
This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart.
:::warning
The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary.

View File

@ -255,6 +255,8 @@ ffmpeg:
# Optional: Detect configuration
# NOTE: Can be overridden at the camera level
detect:
# Optional: enables detection for the camera (default: shown below)
enabled: False
# Optional: width of the frame for the input with the detect role (default: use native stream resolution)
width: 1280
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
@ -262,8 +264,6 @@ detect:
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
fps: 5
# Optional: enables detection for the camera (default: True)
enabled: True
# Optional: Number of consecutive detection hits required for an object to be initialized in the tracker. (default: 1/2 the frame rate)
min_initialized: 2
# Optional: Number of frames without a detection before Frigate considers an object to be gone. (default: 5x the frame rate)
@ -813,6 +813,12 @@ cameras:
- cat
# Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify)
required_zones: []
# Optional: What triggers to use to send frames for a tracked object to generative AI (default: shown below)
send_triggers:
# Once the object is no longer tracked
tracked_object_end: True
# Optional: After X many significant updates are received (default: shown below)
after_significant_updates: None
# Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below)
debug_save_thumbnails: False

View File

@ -151,8 +151,6 @@ cameras:
- path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection
roles:
- detect
detect:
enabled: False # <---- disable detection until you have a working camera feed
```
### Step 2: Start Frigate
@ -307,7 +305,7 @@ By default, Frigate will retain video of all tracked objects for 10 days. The fu
### Step 7: Complete config
At this point you have a complete config with basic functionality.
At this point you have a complete config with basic functionality.
- View [common configuration examples](../configuration/index.md#common-configuration-examples) for a list of common configuration examples.
- View [full config reference](../configuration/reference.md) for a complete list of configuration options.

View File

@ -32,6 +32,7 @@ class StationaryConfig(FrigateBaseModel):
class DetectConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Detection Enabled.")
height: Optional[int] = Field(
default=None, title="Height of the stream for the detect role."
)
@ -41,7 +42,6 @@ class DetectConfig(FrigateBaseModel):
fps: int = Field(
default=5, title="Number of frames per second to process through detection."
)
enabled: bool = Field(default=True, title="Detection Enabled.")
min_initialized: Optional[int] = Field(
default=None,
title="Minimum number of consecutive hits for an object to be initialized by the tracker.",

View File

@ -16,6 +16,17 @@ class GenAIProviderEnum(str, Enum):
ollama = "ollama"
class GenAISendTriggersConfig(BaseModel):
tracked_object_end: bool = Field(
default=True, title="Send once the object is no longer tracked."
)
after_significant_updates: Optional[int] = Field(
default=None,
title="Send an early request to generative AI when X frames accumulated.",
ge=1,
)
# uses BaseModel because some global attributes are not available at the camera level
class GenAICameraConfig(BaseModel):
enabled: bool = Field(default=False, title="Enable GenAI for camera.")
@ -42,6 +53,10 @@ class GenAICameraConfig(BaseModel):
default=False,
title="Save thumbnails sent to generative AI for debugging purposes.",
)
send_triggers: GenAISendTriggersConfig = Field(
default_factory=GenAISendTriggersConfig,
title="What triggers to use to send frames to generative AI for a tracked object.",
)
@field_validator("required_zones", mode="before")
@classmethod

View File

@ -48,7 +48,11 @@ from frigate.genai import get_genai_client
from frigate.models import Event
from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.builtin import serialize
from frigate.util.image import SharedMemoryFrameManager, calculate_region
from frigate.util.image import (
SharedMemoryFrameManager,
calculate_region,
ensure_jpeg_bytes,
)
from frigate.util.path import get_event_thumbnail_bytes
from .embeddings import Embeddings
@ -128,6 +132,7 @@ class EmbeddingMaintainer(threading.Thread):
self.stop_event = stop_event
self.tracked_events: dict[str, list[any]] = {}
self.early_request_sent: dict[str, bool] = {}
self.genai_client = get_genai_client(config)
# recordings data
@ -236,6 +241,43 @@ class EmbeddingMaintainer(threading.Thread):
self.tracked_events[data["id"]].append(data)
# check if we're configured to send an early request after a minimum number of updates received
if (
self.genai_client is not None
and camera_config.genai.send_triggers.after_significant_updates
):
if (
len(self.tracked_events.get(data["id"], []))
>= camera_config.genai.send_triggers.after_significant_updates
and data["id"] not in self.early_request_sent
):
if data["has_clip"] and data["has_snapshot"]:
event: Event = Event.get(Event.id == data["id"])
if (
not camera_config.genai.objects
or event.label in camera_config.genai.objects
) and (
not camera_config.genai.required_zones
or set(data["entered_zones"])
& set(camera_config.genai.required_zones)
):
logger.debug(f"{camera} sending early request to GenAI")
self.early_request_sent[data["id"]] = True
threading.Thread(
target=self._genai_embed_description,
name=f"_genai_embed_description_{event.id}",
daemon=True,
args=(
event,
[
data["thumbnail"]
for data in self.tracked_events[data["id"]]
],
),
).start()
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None:
@ -296,8 +338,8 @@ class EmbeddingMaintainer(threading.Thread):
# Run GenAI
if (
camera_config.genai.enabled
and camera_config.genai.send_triggers.tracked_object_end
and self.genai_client is not None
and event.data.get("description") is None
and (
not camera_config.genai.objects
or event.label in camera_config.genai.objects
@ -374,6 +416,9 @@ class EmbeddingMaintainer(threading.Thread):
num_thumbnails = len(self.tracked_events.get(event.id, []))
# ensure we have a jpeg to pass to the model
thumbnail = ensure_jpeg_bytes(thumbnail)
embed_image = (
[snapshot_image]
if event.has_snapshot and camera_config.genai.use_snapshot
@ -503,6 +548,9 @@ class EmbeddingMaintainer(threading.Thread):
thumbnail = get_event_thumbnail_bytes(event)
# ensure we have a jpeg to pass to the model
thumbnail = ensure_jpeg_bytes(thumbnail)
logger.debug(
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"
)

View File

@ -135,8 +135,13 @@ class AudioEventMaintainer(threading.Thread):
# create communication for audio detections
self.requestor = InterProcessRequestor()
self.config_subscriber = ConfigSubscriber(f"config/audio/{camera.name}")
self.enabled_subscriber = ConfigSubscriber(
f"config/enabled/{camera.name}", True
)
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio)
self.was_enabled = camera.enabled
def detect_audio(self, audio) -> None:
if not self.config.audio.enabled or self.stop_event.is_set():
return
@ -248,6 +253,23 @@ class AudioEventMaintainer(threading.Thread):
f"Failed to end audio event {detection['id']} with status code {resp.status_code}"
)
def expire_all_detections(self) -> None:
"""Immediately end all current detections"""
now = datetime.datetime.now().timestamp()
for label, detection in list(self.detections.items()):
if detection:
self.requestor.send_data(f"{self.config.name}/audio/{label}", "OFF")
resp = requests.put(
f"{FRIGATE_LOCALHOST}/api/events/{detection['id']}/end",
json={"end_time": now},
)
if resp.status_code == 200:
self.detections[label] = None
else:
self.logger.warning(
f"Failed to end audio event {detection['id']} with status code {resp.status_code}"
)
def start_or_restart_ffmpeg(self) -> None:
self.audio_listener = start_or_restart_ffmpeg(
self.ffmpeg_cmd,
@ -283,10 +305,41 @@ class AudioEventMaintainer(threading.Thread):
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
log_and_restart()
def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
_, config_data = self.enabled_subscriber.check_for_update()
if config_data:
self.config.enabled = config_data.enabled
return config_data.enabled
return self.config.enabled
def run(self) -> None:
self.start_or_restart_ffmpeg()
if self._update_enabled_state():
self.start_or_restart_ffmpeg()
while not self.stop_event.is_set():
enabled = self._update_enabled_state()
if enabled != self.was_enabled:
if enabled:
self.logger.debug(
f"Enabling audio detections for {self.config.name}"
)
self.start_or_restart_ffmpeg()
else:
self.logger.debug(
f"Disabling audio detections for {self.config.name}, ending events"
)
self.expire_all_detections()
stop_ffmpeg(self.audio_listener, self.logger)
self.audio_listener = None
self.was_enabled = enabled
continue
if not enabled:
time.sleep(0.1)
continue
# check if there is an updated config
(
updated_topic,
@ -302,6 +355,7 @@ class AudioEventMaintainer(threading.Thread):
self.logpipe.close()
self.requestor.stop()
self.config_subscriber.stop()
self.enabled_subscriber.stop()
self.detection_publisher.stop()

View File

@ -440,10 +440,7 @@ class TrackedObjectProcessor(threading.Thread):
self.last_motion_detected: dict[str, float] = {}
self.ptz_autotracker_thread = ptz_autotracker_thread
self.enabled_subscribers = {
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
for camera in config.cameras.keys()
}
self.config_enabled_subscriber = ConfigSubscriber("config/enabled/")
self.requestor = InterProcessRequestor()
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
@ -705,24 +702,34 @@ class TrackedObjectProcessor(threading.Thread):
{"enabled": False, "motion": 0, "objects": []},
)
def _get_enabled_state(self, camera: str) -> bool:
_, config_data = self.enabled_subscribers[camera].check_for_update()
if config_data:
self.config.cameras[camera].enabled = config_data.enabled
if self.camera_states[camera].prev_enabled is None:
self.camera_states[camera].prev_enabled = config_data.enabled
return self.config.cameras[camera].enabled
def run(self):
while not self.stop_event.is_set():
# check for config updates
while True:
(
updated_enabled_topic,
updated_enabled_config,
) = self.config_enabled_subscriber.check_for_update()
if not updated_enabled_topic:
break
camera_name = updated_enabled_topic.rpartition("/")[-1]
self.config.cameras[
camera_name
].enabled = updated_enabled_config.enabled
if self.camera_states[camera_name].prev_enabled is None:
self.camera_states[
camera_name
].prev_enabled = updated_enabled_config.enabled
# manage camera disabled state
for camera, config in self.config.cameras.items():
if not config.enabled_in_config:
continue
current_enabled = self._get_enabled_state(camera)
current_enabled = config.enabled
camera_state = self.camera_states[camera]
if camera_state.prev_enabled and not current_enabled:
@ -746,7 +753,7 @@ class TrackedObjectProcessor(threading.Thread):
except queue.Empty:
continue
if not self._get_enabled_state(camera):
if not self.config.cameras[camera].enabled:
logger.debug(f"Camera {camera} disabled, skipping update")
continue
@ -792,7 +799,6 @@ class TrackedObjectProcessor(threading.Thread):
self.detection_publisher.stop()
self.event_sender.stop()
self.event_end_subscriber.stop()
for subscriber in self.enabled_subscribers.values():
subscriber.stop()
self.config_enabled_subscriber.stop()
logger.info("Exiting object processor...")

View File

@ -281,12 +281,6 @@ class BirdsEyeFrameManager:
self.stop_event = stop_event
self.inactivity_threshold = config.birdseye.inactivity_threshold
self.enabled_subscribers = {
cam: ConfigSubscriber(f"config/enabled/{cam}", True)
for cam in config.cameras.keys()
if config.cameras[cam].enabled_in_config
}
if config.birdseye.layout.max_cameras:
self.last_refresh_time = 0
@ -387,16 +381,6 @@ class BirdsEyeFrameManager:
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
return True
def _get_enabled_state(self, camera: str) -> bool:
"""Fetch the latest enabled state for a camera from ZMQ."""
_, config_data = self.enabled_subscribers[camera].check_for_update()
if config_data:
self.config.cameras[camera].enabled = config_data.enabled
return config_data.enabled
return self.config.cameras[camera].enabled
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
"""
Update birdseye, optionally with a new frame.
@ -410,7 +394,7 @@ class BirdsEyeFrameManager:
for cam, cam_data in self.cameras.items()
if self.config.cameras[cam].birdseye.enabled
and self.config.cameras[cam].enabled_in_config
and self._get_enabled_state(cam)
and self.config.cameras[cam].enabled
and cam_data["last_active_frame"] > 0
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.inactivity_threshold
@ -706,11 +690,11 @@ class BirdsEyeFrameManager:
frame: np.ndarray,
) -> bool:
# don't process if birdseye is disabled for this camera
camera_config = self.config.cameras[camera].birdseye
camera_config = self.config.cameras[camera]
force_update = False
# disabling birdseye is a little tricky
if not self._get_enabled_state(camera):
if not camera_config.birdseye.enabled or not camera_config.enabled:
# if we've rendered a frame (we have a value for last_active_frame)
# then we need to set it to zero
if self.cameras[camera]["last_active_frame"] > 0:
@ -722,7 +706,7 @@ class BirdsEyeFrameManager:
# update the last active frame for the camera
self.cameras[camera]["current_frame"] = frame.copy()
self.cameras[camera]["current_frame_time"] = frame_time
if self.camera_active(camera_config.mode, object_count, motion_count):
if self.camera_active(camera_config.birdseye.mode, object_count, motion_count):
self.cameras[camera]["last_active_frame"] = frame_time
now = datetime.datetime.now().timestamp()
@ -745,11 +729,6 @@ class BirdsEyeFrameManager:
return True
return False
def stop(self):
"""Clean up subscribers when stopping."""
for subscriber in self.enabled_subscribers.values():
subscriber.stop()
class Birdseye:
def __init__(
@ -775,7 +754,8 @@ class Birdseye:
"birdseye", self.converter, websocket_server, stop_event
)
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
self.config_subscriber = ConfigSubscriber("config/birdseye/")
self.config_enabled_subscriber = ConfigSubscriber("config/enabled/")
self.birdseye_subscriber = ConfigSubscriber("config/birdseye/")
self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
@ -815,15 +795,27 @@ class Birdseye:
# check if there is an updated config
while True:
(
updated_topic,
updated_birdseye_topic,
updated_birdseye_config,
) = self.config_subscriber.check_for_update()
) = self.birdseye_subscriber.check_for_update()
if not updated_topic:
(
updated_enabled_topic,
updated_enabled_config,
) = self.config_enabled_subscriber.check_for_update()
if not updated_birdseye_topic and not updated_enabled_topic:
break
camera_name = updated_topic.rpartition("/")[-1]
self.config.cameras[camera_name].birdseye = updated_birdseye_config
if updated_birdseye_config:
camera_name = updated_birdseye_topic.rpartition("/")[-1]
self.config.cameras[camera_name].birdseye = updated_birdseye_config
if updated_enabled_config:
camera_name = updated_enabled_topic.rpartition("/")[-1]
self.config.cameras[
camera_name
].enabled = updated_enabled_config.enabled
if self.birdseye_manager.update(
camera,
@ -835,7 +827,7 @@ class Birdseye:
self.__send_new_frame()
def stop(self) -> None:
self.config_subscriber.stop()
self.birdseye_manager.stop()
self.birdseye_subscriber.stop()
self.config_enabled_subscriber.stop()
self.converter.join()
self.broadcaster.join()

View File

@ -41,22 +41,30 @@ def check_disabled_camera_update(
has_enabled_camera = False
for camera, last_update in write_times.items():
offline_time = now - last_update
if config.cameras[camera].enabled:
has_enabled_camera = True
else:
# flag camera as offline when it is disabled
previews[camera].flag_offline(now)
if now - last_update > 1:
# last camera update was more than one second ago
# need to send empty data to updaters because current
if offline_time > 1:
# last camera update was more than 1 second ago
# need to send empty data to birdseye because current
# frame is now out of date
frame = get_blank_yuv_frame(
config.cameras[camera].detect.width,
config.cameras[camera].detect.height,
)
if birdseye:
birdseye.write_data(camera, [], [], now, frame)
previews[camera].write_data([], [], now, frame)
if birdseye and offline_time < 10:
# we only need to send blank frames to birdseye at the beginning of a camera being offline
birdseye.write_data(
camera,
[],
[],
now,
get_blank_yuv_frame(
config.cameras[camera].detect.width,
config.cameras[camera].detect.height,
),
)
if not has_enabled_camera and birdseye:
birdseye.all_cameras_disabled()
@ -170,6 +178,12 @@ def output_frames(
else:
failed_frame_requests[camera] = 0
# send frames for low fps recording
preview_recorders[camera].write_data(
current_tracked_objects, motion_boxes, frame_time, frame
)
preview_write_times[camera] = frame_time
# send camera frame to ffmpeg process if websockets are connected
if any(
ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager
@ -193,11 +207,6 @@ def output_frames(
frame,
)
# send frames for low fps recording
preview_recorders[camera].write_data(
current_tracked_objects, motion_boxes, frame_time, frame
)
preview_write_times[camera] = frame_time
frame_manager.close(frame_name)
move_preview_frames("clips")

View File

@ -23,7 +23,7 @@ from frigate.ffmpeg_presets import (
)
from frigate.models import Previews
from frigate.object_processing import TrackedObject
from frigate.util.image import copy_yuv_to_position, get_yuv_crop
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
logger = logging.getLogger(__name__)
@ -153,6 +153,7 @@ class PreviewRecorder:
self.config = config
self.start_time = 0
self.last_output_time = 0
self.offline = False
self.output_frames = []
if config.detect.width > config.detect.height:
@ -241,6 +242,17 @@ class PreviewRecorder:
self.last_output_time = ts
self.output_frames.append(ts)
def reset_frame_cache(self, frame_time: float) -> None:
self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
self.start_time = frame_time
self.last_output_time = frame_time
self.output_frames: list[float] = []
def should_write_frame(
self,
current_tracked_objects: list[dict[str, any]],
@ -307,7 +319,9 @@ class PreviewRecorder:
motion_boxes: list[list[int]],
frame_time: float,
frame: np.ndarray,
) -> bool:
) -> None:
self.offline = False
# check for updated record config
_, updated_record_config = self.config_subscriber.check_for_update()
@ -319,7 +333,7 @@ class PreviewRecorder:
self.start_time = frame_time
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
return False
return
# check if PREVIEW clip should be generated and cached frames reset
if frame_time >= self.segment_end:
@ -340,32 +354,35 @@ class PreviewRecorder:
f"Not saving preview for {self.config.name} because there are no saved frames."
)
# reset frame cache
self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
self.start_time = frame_time
self.last_output_time = frame_time
self.output_frames: list[float] = []
self.reset_frame_cache(frame_time)
# include first frame to ensure consistent duration
if self.config.record.enabled:
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
return True
return
elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time):
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
return False
return
def flag_offline(self, frame_time: float) -> None:
if not self.offline:
self.write_frame_to_cache(
frame_time,
get_blank_yuv_frame(
self.config.detect.width, self.config.detect.height
),
)
self.offline = True
# check if PREVIEW clip should be generated and cached frames reset
if frame_time >= self.segment_end:
if len(self.output_frames) == 0:
# camera has been offline for entire hour
# we have no preview to create
self.reset_frame_cache(frame_time)
return
old_frame_path = get_cache_image_name(
@ -382,16 +399,7 @@ class PreviewRecorder:
self.requestor,
).start()
# reset frame cache
self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
self.start_time = frame_time
self.last_output_time = frame_time
self.output_frames = []
self.reset_frame_cache(frame_time)
def stop(self) -> None:
self.requestor.stop()

View File

@ -150,6 +150,7 @@ class ReviewSegmentMaintainer(threading.Thread):
self.requestor = InterProcessRequestor()
self.record_config_subscriber = ConfigSubscriber("config/record/")
self.review_config_subscriber = ConfigSubscriber("config/review/")
self.enabled_config_subscriber = ConfigSubscriber("config/enabled/")
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all)
# manual events
@ -450,7 +451,16 @@ class ReviewSegmentMaintainer(threading.Thread):
updated_review_config,
) = self.review_config_subscriber.check_for_update()
if not updated_record_topic and not updated_review_topic:
(
updated_enabled_topic,
updated_enabled_config,
) = self.enabled_config_subscriber.check_for_update()
if (
not updated_record_topic
and not updated_review_topic
and not updated_enabled_topic
):
break
if updated_record_topic:
@ -461,6 +471,12 @@ class ReviewSegmentMaintainer(threading.Thread):
camera_name = updated_review_topic.rpartition("/")[-1]
self.config.cameras[camera_name].review = updated_review_config
if updated_enabled_config:
camera_name = updated_enabled_topic.rpartition("/")[-1]
self.config.cameras[
camera_name
].enabled = updated_enabled_config.enabled
(topic, data) = self.detection_subscriber.check_for_update(timeout=1)
if not topic:
@ -494,7 +510,10 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment = self.active_review_segments.get(camera)
if not self.config.cameras[camera].record.enabled:
if (
not self.config.cameras[camera].enabled
or not self.config.cameras[camera].record.enabled
):
if current_segment:
self.end_segment(camera)
continue

View File

@ -300,6 +300,12 @@ def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
"""Handle migrating frigate config to 0.16-0"""
new_config = config.copy()
# migrate config that does not have detect -> enabled explicitly set to have it enabled
if new_config.get("detect", {}).get("enabled") is None:
detect_config = new_config.get("detect", {})
detect_config["enabled"] = True
new_config["detect"] = detect_config
for name, camera in config.get("cameras", {}).items():
camera_config: dict[str, dict[str, any]] = camera.copy()

View File

@ -975,3 +975,22 @@ def get_histogram(image, x_min, y_min, x_max, y_max):
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
)
return cv2.normalize(hist, hist).flatten()
def ensure_jpeg_bytes(image_data):
"""Ensure image data is jpeg bytes for genai"""
try:
img_array = np.frombuffer(image_data, dtype=np.uint8)
img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
if img is None:
return image_data
success, encoded_img = cv2.imencode(".jpg", img)
if success:
return encoded_img.tobytes()
except Exception as e:
logger.warning(f"Error when converting thumbnail to jpeg for genai: {e}")
return image_data

View File

@ -362,7 +362,7 @@ def get_intel_gpu_stats(sriov: bool) -> dict[str, str]:
if video_frame is not None:
video[key].append(float(video_frame))
if render["global"]:
if render["global"] and video["global"]:
results["gpu"] = (
f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%"
)

View File

@ -197,9 +197,10 @@ class CameraWatchdog(threading.Thread):
"""Fetch the latest config and update enabled state."""
_, config_data = self.config_subscriber.check_for_update()
if config_data:
enabled = config_data.enabled
return enabled
return self.was_enabled if self.was_enabled is not None else self.config.enabled
self.config.enabled = config_data.enabled
return config_data.enabled
return self.config.enabled
def run(self):
if self._update_enabled_state():

2442
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,47 +14,47 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.1",
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^3.9.0",
"@melloware/react-logviewer": "^6.1.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"apexcharts": "^3.52.0",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4",
"hls.js": "^1.5.17",
"hls.js": "^1.5.20",
"i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1",
"immer": "^10.1.1",
"konva": "^9.3.16",
"konva": "^9.3.18",
"lodash": "^4.17.21",
"lucide-react": "^0.407.0",
"monaco-yaml": "^5.2.2",
"lucide-react": "^0.477.0",
"monaco-yaml": "^5.3.1",
"next-themes": "^0.3.0",
"nosleep.js": "^0.12.0",
"react": "^18.3.1",
@ -65,10 +65,10 @@
"react-grid-layout": "^1.5.0",
"react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0",
"react-icons": "^5.2.1",
"react-icons": "^5.5.0",
"react-konva": "^18.2.10",
"react-router-dom": "^6.26.0",
"react-swipeable": "^7.0.1",
"react-swipeable": "^7.0.2",
"react-tracked": "^2.0.1",
"react-transition-group": "^4.4.5",
"react-use-websocket": "^4.8.1",
@ -78,7 +78,7 @@
"sonner": "^1.5.0",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.2.5",
"swr": "^2.3.2",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
@ -100,8 +100,8 @@
"@types/strftime": "^0.9.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.7.1",
"@vitest/coverage-v8": "^2.0.5",
"@vitejs/plugin-react-swc": "^3.8.0",
"@vitest/coverage-v8": "^3.0.7",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
@ -118,8 +118,8 @@
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.9",
"typescript": "^5.5.4",
"vite": "^5.4.0",
"vitest": "^2.0.5"
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vitest": "^3.0.7"
}
}

View File

@ -20,6 +20,7 @@ type CameraFilterButtonProps = {
groups: [string, CameraGroupConfig][];
selectedCameras: string[] | undefined;
hideText?: boolean;
mainCamera?: string;
updateCameraFilter: (cameras: string[] | undefined) => void;
};
export function CamerasFilterButton({
@ -27,6 +28,7 @@ export function CamerasFilterButton({
groups,
selectedCameras,
hideText = isMobile,
mainCamera,
updateCameraFilter,
}: CameraFilterButtonProps) {
const [open, setOpen] = useState(false);
@ -76,6 +78,7 @@ export function CamerasFilterButton({
allCameras={allCameras}
groups={groups}
currentCameras={currentCameras}
mainCamera={mainCamera}
setCurrentCameras={setCurrentCameras}
setOpen={setOpen}
updateCameraFilter={updateCameraFilter}
@ -122,6 +125,7 @@ export function CamerasFilterButton({
type CamerasFilterContentProps = {
allCameras: string[];
currentCameras: string[] | undefined;
mainCamera?: string;
groups: [string, CameraGroupConfig][];
setCurrentCameras: (cameras: string[] | undefined) => void;
setOpen: (open: boolean) => void;
@ -130,6 +134,7 @@ type CamerasFilterContentProps = {
export function CamerasFilterContent({
allCameras,
currentCameras,
mainCamera,
groups,
setCurrentCameras,
setOpen,
@ -180,12 +185,29 @@ export function CamerasFilterContent({
key={item}
isChecked={currentCameras?.includes(item) ?? false}
label={item.replaceAll("_", " ")}
disabled={
mainCamera !== undefined &&
currentCameras !== undefined &&
item === mainCamera
} // Disable only if mainCamera exists and cameras are filtered
onCheckedChange={(isChecked) => {
if (
mainCamera !== undefined && // Only enforce if mainCamera is defined
item === mainCamera &&
!isChecked &&
currentCameras !== undefined
) {
return; // Prevent deselecting mainCamera when filtered and mainCamera is defined
}
if (isChecked) {
const updatedCameras = currentCameras
? [...currentCameras]
: [];
updatedCameras.push(item);
: mainCamera !== undefined && item !== mainCamera // If mainCamera exists and this isnt it
? [mainCamera] // Start with mainCamera when transitioning from undefined
: []; // Otherwise start empty
if (!updatedCameras.includes(item)) {
updatedCameras.push(item);
}
setCurrentCameras(updatedCameras);
} else {
const updatedCameras = currentCameras

View File

@ -51,6 +51,7 @@ type ReviewFilterGroupProps = {
motionOnly: boolean;
filterList?: FilterList;
showReviewed: boolean;
mainCamera?: string;
setShowReviewed: (show: boolean) => void;
onUpdateFilter: (filter: ReviewFilter) => void;
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
@ -65,6 +66,7 @@ export default function ReviewFilterGroup({
motionOnly,
filterList,
showReviewed,
mainCamera,
setShowReviewed,
onUpdateFilter,
setMotionOnly,
@ -187,6 +189,7 @@ export default function ReviewFilterGroup({
allCameras={filterValues.cameras}
groups={groups}
selectedCameras={filter?.cameras}
mainCamera={mainCamera}
updateCameraFilter={(newCameras) => {
onUpdateFilter({ ...filter, cameras: newCameras });
}}

View File

@ -16,9 +16,9 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { getUnitSize } from "@/utils/storageUtil";
import { LuAlertCircle } from "react-icons/lu";
import { Trans } from "react-i18next";
import { t } from "i18next";
import { CiCircleAlert } from "react-icons/ci";
type CameraStorage = {
[key: string]: {
@ -213,7 +213,7 @@ export function CombinedStorageGraph({
className="focus:outline-none"
aria-label="Unused Storage Information"
>
<LuAlertCircle
<CiCircleAlert
className="size-5"
aria-label="Unused Storage Information"
/>

View File

@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { LuLoader2 } from "react-icons/lu";
import { AiOutlineLoading3Quarters } from "react-icons/ai";
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
return (
@ -7,7 +7,7 @@ export default function ActivityIndicator({ className = "w-full", size = 30 }) {
className={cn("flex items-center justify-center", className)}
aria-label="Loading…"
>
<LuLoader2 className="animate-spin" size={size} />
<AiOutlineLoading3Quarters className="animate-spin" size={size} />
</div>
);
}

View File

@ -6,7 +6,7 @@ import {
LuList,
LuLogOut,
LuMoon,
LuPenSquare,
LuSquarePen,
LuRotateCw,
LuSettings,
LuSun,
@ -215,7 +215,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
}
aria-label="Configuration editor"
>
<LuPenSquare className="mr-2 size-4" />
<LuSquarePen className="mr-2 size-4" />
<span>
<Trans>ui.configurationEditor</Trans>
</span>

View File

@ -4,7 +4,8 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner";
import axios from "axios";
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi";
import { FaArrowsRotate } from "react-icons/fa6";
import { MdImageSearch } from "react-icons/md";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
@ -233,7 +234,7 @@ export default function SearchResultActions({
<DropdownMenu>
<DropdownMenuTrigger>
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
<FiMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
</DropdownMenu>

View File

@ -23,7 +23,6 @@ import {
LuEar,
LuFolderX,
LuPlay,
LuPlayCircle,
LuSettings,
LuTruck,
} from "react-icons/lu";
@ -54,6 +53,7 @@ import {
import { useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5";
type ObjectLifecycleProps = {
className?: string;
@ -733,7 +733,7 @@ export function LifecycleIcon({
case "gone":
return <IoMdExit className={cn(className)} />;
case "active":
return <LuPlayCircle className={cn(className)} />;
return <IoPlayCircleOutline className={cn(className)} />;
case "stationary":
return <LuCircle className={cn(className)} />;
case "entered_zone":

View File

@ -3,7 +3,7 @@ import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect";
import useSWR from "swr";
import { useApiHost } from "@/api";
import { cn } from "@/lib/utils";
import { LuArrowRightCircle } from "react-icons/lu";
import { BsArrowRightCircle } from "react-icons/bs";
import { useNavigate } from "react-router-dom";
import {
Tooltip,
@ -183,7 +183,7 @@ function ThumbnailRow({
>
<Tooltip>
<TooltipTrigger>
<LuArrowRightCircle
<BsArrowRightCircle
className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
size={24}
/>

View File

@ -364,6 +364,28 @@ export default function LiveCameraView({
}
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
// On mobile devices that support it, try to orient screen
// to best fit the camera feed in fullscreen mode
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const screenOrientation = screen.orientation as any;
if (!screenOrientation.lock || !screenOrientation.unlock) {
// Browser does not support ScreenOrientation APIs that we need
return;
}
if (fullscreen) {
const orientationForBestFit =
cameraAspectRatio > 1 ? "landscape" : "portrait";
// If the current device doesn't support locking orientation,
// this promise will reject with an error that we can ignore
screenOrientation.lock(orientationForBestFit).catch(() => {});
}
return () => screenOrientation.unlock();
}, [fullscreen, cameraAspectRatio]);
const handleError = useCallback(
(e: LivePlayerError) => {
if (e) {

View File

@ -451,7 +451,7 @@ export function RecordingView({
)}
{isDesktop && (
<ReviewFilterGroup
filters={["date", "general"]}
filters={["cameras", "date", "general"]}
reviewSummary={reviewSummary}
recordingsSummary={recordingsSummary}
filter={filter}
@ -459,7 +459,22 @@ export function RecordingView({
filterList={reviewFilterList}
showReviewed
setShowReviewed={() => {}}
onUpdateFilter={updateFilter}
mainCamera={mainCamera}
onUpdateFilter={(newFilter: ReviewFilter) => {
const updatedCameras =
newFilter.cameras === undefined
? undefined // Respect undefined as "all cameras"
: newFilter.cameras
? Array.from(
new Set([mainCamera, ...(newFilter.cameras || [])]),
) // Include mainCamera if specific cameras are selected
: [mainCamera];
const adjustedFilter: ReviewFilter = {
...newFilter,
cameras: updatedCameras,
};
updateFilter(adjustedFilter);
}}
setMotionOnly={() => {}}
/>
)}

View File

@ -22,7 +22,8 @@ import { t } from "i18next";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { Trans } from "react-i18next";
import { LuAlertCircle, LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
@ -313,7 +314,7 @@ export default function NotificationView({
</div>
</div>
<Alert variant="destructive">
<LuAlertCircle className="size-5" />
<CiCircleAlert className="size-5" />
<AlertTitle>Notifications Unavailable</AlertTitle>
<AlertDescription>

View File

@ -8,8 +8,8 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import useSWR from "swr";
import { LuAlertCircle } from "react-icons/lu";
import { Trans } from "react-i18next";
import { CiCircleAlert } from "react-icons/ci";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTimezone } from "@/hooks/use-date-utils";
import { RecordingsSummary } from "@/types/review";
@ -89,7 +89,7 @@ export default function StorageMetrics({
className="focus:outline-none"
aria-label="Unused Storage Information"
>
<LuAlertCircle
<CiCircleAlert
className="size-5"
aria-label="Unused Storage Information"
/>