mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-27 02:28:22 +03:00
Compare commits
8 Commits
1c30b8781b
...
d190809f38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d190809f38 | ||
|
|
b2118382cb | ||
|
|
95956a690b | ||
|
|
5e7d426768 | ||
|
|
e888dc347a | ||
|
|
aeab5705ea | ||
|
|
dc865d51dc | ||
|
|
a7925e9b9f |
16
.github/copilot-instructions.md
vendored
16
.github/copilot-instructions.md
vendored
@ -324,6 +324,12 @@ try:
|
||||
value = await sensor.read()
|
||||
except Exception: # ❌ Too broad
|
||||
logger.error("Failed")
|
||||
|
||||
# Returning exceptions in JSON responses
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
)
|
||||
```
|
||||
|
||||
### ✅ Use These Instead
|
||||
@ -353,6 +359,16 @@ try:
|
||||
value = await sensor.read()
|
||||
except SensorException as err: # ✅ Specific
|
||||
logger.exception("Failed to read sensor")
|
||||
|
||||
# Safe error responses
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for API request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid request parameters",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
@ -5,6 +5,18 @@ title: Face Recognition
|
||||
|
||||
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||
|
||||
## Alerts and Notifications
|
||||
|
||||
Face recognition does not affect whether an alert is created — alerts are based on tracked objects like `person` in your `review.alerts.labels` and your [zone requirements](./review). The `face` label is an [attribute label](/plus/#available-label-types), not a tracked object, so it cannot trigger alerts on its own.
|
||||
|
||||
When a face is recognized, the person's name is added as a `sub_label` on the tracked object. This name appears in the Frigate UI, in [built-in notifications](/configuration/notifications), and is published via [MQTT](/integrations/mqtt).
|
||||
|
||||
:::note
|
||||
|
||||
There is no built-in way to only create alerts for specific recognized faces. Neither `face`, `person-verified`, nor specific person names can be used in `review.alerts.labels`. To trigger automations based on face recognition results, use the [official Frigate integration's sensors](/integrations/home-assistant) and/or the [MQTT data](/integrations/mqtt) Frigate publishes.
|
||||
|
||||
:::
|
||||
|
||||
## Model Requirements
|
||||
|
||||
### Face Detection
|
||||
@ -69,9 +81,9 @@ Fine-tune face recognition with these optional parameters at the global level of
|
||||
- Default: `0.9`.
|
||||
- `min_faces`: Min face recognitions for the sub label to be applied to the person object.
|
||||
- Default: `1`
|
||||
- `save_attempts`: Number of images of recognized faces to save for training.
|
||||
- `save_attempts`: Maximum number of face attempt images to keep in the training folder. Frigate saves a face image after each recognition attempt; when the limit is reached, the oldest image is deleted. These images are displayed in the Face Library's Recent Recognitions tab.
|
||||
- Default: `200`.
|
||||
- `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this.
|
||||
- `blur_confidence_filter`: Enables a filter that measures face image blurriness (using Laplacian variance) and reduces the recognition confidence score accordingly. Blurrier images receive a larger penalty (up to -0.06 for very blurry, down to 0 for clear images), making it harder for blurry faces to meet the `recognition_threshold`.
|
||||
- Default: `True`.
|
||||
- `device`: Target a specific device to run the face recognition model on (multi-GPU installation).
|
||||
- Default: `None`.
|
||||
@ -118,9 +130,19 @@ When choosing images to include in the face training set it is recommended to al
|
||||
|
||||
The Recent Recognitions tab in the face library displays recent face recognition attempts. Detected face images are grouped according to the person they were identified as potentially matching.
|
||||
|
||||
Each face image is labeled with a name (or `Unknown`) along with the confidence score of the recognition attempt. While each image can be used to train the system for a specific person, not all images are suitable for training.
|
||||
Each face image is labeled with a name (or `Unknown`) along with the confidence score of the recognition attempt. The score is color-coded based on your configured thresholds:
|
||||
|
||||
Refer to the guidelines below for best practices on selecting images for training.
|
||||
- **Green**: score >= `recognition_threshold` (default `0.9`) — a confident match
|
||||
- **Orange**: score >= `unknown_score` (default `0.8`) — a potential match
|
||||
- **Red**: score < `unknown_score` — unknown or no match
|
||||
|
||||
When an event has multiple recognition attempts, the face cards are displayed within a group. The group shows the recognized person's name if one was identified, or "Unknown" if not. Within the group, each individual face card shows its own recognition score. Frigate uses a weighted average across all attempts for a person object to determine whether to assign a name (`sub_label`) — so a single high-scoring card does not guarantee the person will be identified (see the [FAQ](#i-see-scores-above-the-threshold-in-the-recent-recognitions-tab-but-a-sub-label-wasnt-assigned) for more details).
|
||||
|
||||
If the weighted average did not meet the `recognition_threshold`, there is no place in the UI to see it. The weighted average is published in the `score` field of the [`frigate/tracked_object_update`](/integrations/mqtt.md#face-recognition-update) MQTT topic after each recognition attempt, regardless of whether it meets the threshold. This is the most useful tool for debugging why a sub label was or wasn't assigned.
|
||||
|
||||
Clicking a face card navigates to the Tracked Object Details for the associated event. To select face cards for deletion, right-click (or Ctrl/Cmd+click) individual cards, or use Ctrl+A to select all. A delete button will appear in the toolbar once cards are selected. Removing cards from the Recent Recognitions tab only removes the saved attempt images — it does not affect recognition accuracy or training data.
|
||||
|
||||
While each image can be used to train the system for a specific person, not all images are suitable for training. Refer to the guidelines below for best practices on selecting images for training.
|
||||
|
||||
### Step 1 - Building a Strong Foundation
|
||||
|
||||
@ -157,6 +179,8 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
|
||||
- Make sure you have trained at least one face per the recommendations above.
|
||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||
|
||||
3. To see recognition scores for an event, check the **Face Library** > **Recent Recognitions** tab. Face cards from the same event are grouped together, with the group header showing the combined result. Each card within the group shows its individual recognition score with [color coding](#understanding-the-recent-recognitions-tab). The **Tracked Object Details** view only shows the final weighted average score (in parentheses next to the top score) if a `sub_label` was assigned.
|
||||
|
||||
### Detection does not work well with blurry images?
|
||||
|
||||
Accuracy is definitely a going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work.
|
||||
@ -190,7 +214,15 @@ For more guidance, refer to the section above on improving recognition accuracy.
|
||||
|
||||
### I see scores above the threshold in the Recent Recognitions tab, but a sub label wasn't assigned?
|
||||
|
||||
The Frigate considers the recognition scores across all recognition attempts for each person object. The scores are continually weighted based on the area of the face, and a sub label will only be assigned to person if a person is confidently recognized consistently. This avoids cases where a single high confidence recognition would throw off the results.
|
||||
Frigate considers recognition scores across all attempts for each person object. The score shown in the UI is the final weighted average across all attempts, while MQTT publishes a running weighted average that updates after each attempt. The weighting favors larger faces (by pixel area, capped at 4000px) and higher-confidence detections. Attempts scored at or below `unknown_score` are excluded from the average.
|
||||
|
||||
A sub label will only be assigned if:
|
||||
|
||||
- At least `min_faces` recognition attempts have been recorded.
|
||||
- A single person name has the most detections (no ties).
|
||||
- The weighted average score meets the `recognition_threshold`.
|
||||
|
||||
This avoids cases where a single high-confidence recognition would throw off the results.
|
||||
|
||||
### Can I use other face recognition software like DoubleTake at the same time as the built in face recognition?
|
||||
|
||||
|
||||
@ -75,4 +75,4 @@ Many providers also have a public facing chat interface for their models. Downlo
|
||||
|
||||
- OpenAI - [ChatGPT](https://chatgpt.com)
|
||||
- Gemini - [Google AI Studio](https://aistudio.google.com)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
- Ollama - [Open WebUI](https://docs.openwebui.com/)
|
||||
|
||||
@ -49,7 +49,7 @@ Once notifications are enabled, press the `Register for Notifications` button on
|
||||
|
||||
## Supported Notifications
|
||||
|
||||
Currently notifications are only supported for review alerts. More notifications will be supported in the future.
|
||||
Currently notifications are only supported for review alerts. More notifications will be supported in the future. If [face recognition](/configuration/face_recognition) identifies a person during the alert, their name will be included in the notification. However, built-in notifications cannot be configured to only fire for specific recognized faces.
|
||||
|
||||
:::note
|
||||
|
||||
|
||||
@ -49,12 +49,13 @@ from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.config import apply_section_update, find_config_file
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
get_nvidia_driver_info,
|
||||
@ -422,9 +423,100 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
"""Apply config changes in-memory only, without writing to YAML.
|
||||
|
||||
Used for temporary config changes like debug replay camera tuning.
|
||||
Updates the in-memory Pydantic config and publishes ZMQ updates,
|
||||
bypassing YAML parsing entirely.
|
||||
"""
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No configuration data provided"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
# Group flat key paths into nested per-camera, per-section dicts
|
||||
grouped: dict[str, dict[str, dict]] = {}
|
||||
for key_path, value in updates.items():
|
||||
parts = key_path.split(".")
|
||||
if len(parts) < 3 or parts[0] != "cameras":
|
||||
continue
|
||||
|
||||
cam, section = parts[1], parts[2]
|
||||
grouped.setdefault(cam, {}).setdefault(section, {})
|
||||
|
||||
# Build nested dict from remaining path (e.g. "filters.person.threshold")
|
||||
target = grouped[cam][section]
|
||||
for part in parts[3:-1]:
|
||||
target = target.setdefault(part, {})
|
||||
if len(parts) > 3:
|
||||
target[parts[-1]] = value
|
||||
elif isinstance(value, dict):
|
||||
grouped[cam][section] = deep_merge(
|
||||
grouped[cam][section], value, override=True
|
||||
)
|
||||
else:
|
||||
grouped[cam][section] = value
|
||||
|
||||
# Apply each section update
|
||||
for cam_name, sections in grouped.items():
|
||||
camera_config = config.cameras.get(cam_name)
|
||||
if not camera_config:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Camera '{cam_name}' not found",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for section_name, update in sections.items():
|
||||
err = apply_section_update(camera_config, section_name, update)
|
||||
if err is not None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": err},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Publish ZMQ updates so processing threads pick up changes
|
||||
if body.update_topic and body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
settings = getattr(config.cameras.get(camera, None), field, None)
|
||||
|
||||
if settings is not None:
|
||||
request.app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
|
||||
settings,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Config applied in-memory"},
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying config in-memory: {e}")
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Error applying config"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
|
||||
if body.skip_save:
|
||||
return _config_set_in_memory(request, body)
|
||||
|
||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||
|
||||
try:
|
||||
|
||||
176
frigate/api/debug_replay.py
Normal file
176
frigate/api/debug_replay.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""Debug replay API endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.app])
|
||||
|
||||
|
||||
class DebugReplayStartBody(BaseModel):
|
||||
"""Request body for starting a debug replay session."""
|
||||
|
||||
camera: str = Field(title="Source camera name")
|
||||
start_time: float = Field(title="Start timestamp")
|
||||
end_time: float = Field(title="End timestamp")
|
||||
|
||||
|
||||
class DebugReplayStartResponse(BaseModel):
|
||||
"""Response for starting a debug replay session."""
|
||||
|
||||
success: bool
|
||||
replay_camera: str
|
||||
|
||||
|
||||
class DebugReplayStatusResponse(BaseModel):
|
||||
"""Response for debug replay status."""
|
||||
|
||||
active: bool
|
||||
replay_camera: str | None = None
|
||||
source_camera: str | None = None
|
||||
start_time: float | None = None
|
||||
end_time: float | None = None
|
||||
live_ready: bool = False
|
||||
|
||||
|
||||
class DebugReplayStopResponse(BaseModel):
|
||||
"""Response for stopping a debug replay session."""
|
||||
|
||||
success: bool
|
||||
|
||||
|
||||
@router.post(
|
||||
"/debug_replay/start",
|
||||
response_model=DebugReplayStartResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start debug replay",
|
||||
description="Start a debug replay session from camera recordings.",
|
||||
)
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
if replay_manager.active:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "A replay session is already active",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
try:
|
||||
replay_camera = await asyncio.to_thread(
|
||||
replay_manager.start,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Invalid parameters for debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay request parameters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.exception("Error while starting debug replay session")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "An internal error occurred while starting debug replay",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return DebugReplayStartResponse(
|
||||
success=True,
|
||||
replay_camera=replay_camera,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/debug_replay/status",
|
||||
response_model=DebugReplayStatusResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Get debug replay status",
|
||||
description="Get the status of the current debug replay session.",
|
||||
)
|
||||
def get_debug_replay_status(request: Request):
|
||||
"""Get the current replay session status."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
live_ready = False
|
||||
replay_camera = replay_manager.replay_camera_name
|
||||
|
||||
if replay_manager.active and replay_camera:
|
||||
frame_processor = request.app.detected_frames_processor
|
||||
frame = frame_processor.get_current_frame(replay_camera)
|
||||
|
||||
if frame is not None:
|
||||
frame_time = frame_processor.get_current_frame_time(replay_camera)
|
||||
camera_config = request.app.frigate_config.cameras.get(replay_camera)
|
||||
retry_interval = 10
|
||||
|
||||
if camera_config is not None:
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
live_ready = datetime.now().timestamp() <= frame_time + retry_interval
|
||||
|
||||
return DebugReplayStatusResponse(
|
||||
active=replay_manager.active,
|
||||
replay_camera=replay_camera,
|
||||
source_camera=replay_manager.source_camera,
|
||||
start_time=replay_manager.start_ts,
|
||||
end_time=replay_manager.end_ts,
|
||||
live_ready=live_ready,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/debug_replay/stop",
|
||||
response_model=DebugReplayStopResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Stop debug replay",
|
||||
description="Stop the active debug replay session and clean up all artifacts.",
|
||||
)
|
||||
async def stop_debug_replay(request: Request):
|
||||
"""Stop the active replay session."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
if not replay_manager.active:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No active replay session"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
replay_manager.stop,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
)
|
||||
except (ValueError, RuntimeError, OSError) as e:
|
||||
logger.error("Error stopping replay: %s", e)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Failed to stop replay session due to an internal error.",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return DebugReplayStopResponse(success=True)
|
||||
@ -7,6 +7,7 @@ class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
update_topic: str | None = None
|
||||
config_data: Optional[Dict[str, Any]] = None
|
||||
skip_save: bool = False
|
||||
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
|
||||
@ -18,6 +18,7 @@ from frigate.api import (
|
||||
camera,
|
||||
chat,
|
||||
classification,
|
||||
debug_replay,
|
||||
event,
|
||||
export,
|
||||
media,
|
||||
@ -32,6 +33,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@ -65,6 +67,7 @@ def create_fastapi_app(
|
||||
stats_emitter: StatsEmitter,
|
||||
event_metadata_updater: EventMetadataPublisher,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: DebugReplayManager,
|
||||
enforce_default_admin: bool = True,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
@ -133,6 +136,7 @@ def create_fastapi_app(
|
||||
app.include_router(event.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(record.router)
|
||||
app.include_router(debug_replay.router)
|
||||
# App Properties
|
||||
app.frigate_config = frigate_config
|
||||
app.genai_manager = GenAIClientManager(frigate_config)
|
||||
@ -144,6 +148,7 @@ def create_fastapi_app(
|
||||
app.stats_emitter = stats_emitter
|
||||
app.event_metadata_updater = event_metadata_updater
|
||||
app.config_publisher = config_publisher
|
||||
app.replay_manager = replay_manager
|
||||
|
||||
if frigate_config.auth.enabled:
|
||||
secret = get_jwt_secret()
|
||||
|
||||
@ -43,6 +43,10 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.debug_replay import (
|
||||
DebugReplayManager,
|
||||
cleanup_replay_cameras,
|
||||
)
|
||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
from frigate.events.audio import AudioProcessor
|
||||
from frigate.events.cleanup import EventCleanup
|
||||
@ -139,6 +143,9 @@ class FrigateApp:
|
||||
else:
|
||||
logger.debug(f"Skipping directory: {d}")
|
||||
|
||||
def init_debug_replay_manager(self) -> None:
|
||||
self.replay_manager = DebugReplayManager()
|
||||
|
||||
def init_camera_metrics(self) -> None:
|
||||
# create camera_metrics
|
||||
for camera_name in self.config.cameras.keys():
|
||||
@ -531,6 +538,7 @@ class FrigateApp:
|
||||
set_file_limit()
|
||||
|
||||
# Start frigate services.
|
||||
self.init_debug_replay_manager()
|
||||
self.init_camera_metrics()
|
||||
self.init_queues()
|
||||
self.init_database()
|
||||
@ -541,6 +549,10 @@ class FrigateApp:
|
||||
self.init_embeddings_manager()
|
||||
self.bind_database()
|
||||
self.check_db_data_migrations()
|
||||
|
||||
# Clean up any stale replay camera artifacts (filesystem + DB)
|
||||
cleanup_replay_cameras()
|
||||
|
||||
self.init_inter_process_communicator()
|
||||
self.start_detectors()
|
||||
self.init_dispatcher()
|
||||
@ -572,6 +584,7 @@ class FrigateApp:
|
||||
self.stats_emitter,
|
||||
self.event_metadata_updater,
|
||||
self.inter_config_updater,
|
||||
self.replay_manager,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
@ -637,6 +650,7 @@ class FrigateApp:
|
||||
self.record_cleanup.join()
|
||||
self.stats_emitter.join()
|
||||
self.frigate_watchdog.join()
|
||||
self.camera_maintainer.join()
|
||||
self.db.stop()
|
||||
|
||||
# Save embeddings stats to disk
|
||||
|
||||
@ -57,6 +57,9 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
@ -124,7 +127,11 @@ class CameraActivityManager:
|
||||
any_changed = False
|
||||
|
||||
# run through each object and check what topics need to be updated
|
||||
for label in self.config.cameras[camera].objects.track:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
for label in camera_config.objects.track:
|
||||
if label in self.config.model.non_logo_attributes:
|
||||
continue
|
||||
|
||||
@ -174,6 +181,9 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
@ -193,7 +203,11 @@ class AudioActivityManager:
|
||||
def compare_audio_activity(
|
||||
self, camera: str, new_detections: list[tuple[str, float]], now: float
|
||||
) -> None:
|
||||
max_not_heard = self.config.cameras[camera].audio.max_not_heard
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
max_not_heard = camera_config.audio.max_not_heard
|
||||
current = self.current_audio_detections[camera]
|
||||
|
||||
any_changed = False
|
||||
@ -222,6 +236,7 @@ class AudioActivityManager:
|
||||
None,
|
||||
"audio",
|
||||
{},
|
||||
None,
|
||||
),
|
||||
EventMetadataTypeEnum.manual_event_create.value,
|
||||
)
|
||||
|
||||
@ -55,8 +55,20 @@ class CameraMaintainer(threading.Thread):
|
||||
self.shm_count = self.__calculate_shm_frame_count()
|
||||
self.camera_processes: dict[str, mp.Process] = {}
|
||||
self.capture_processes: dict[str, mp.Process] = {}
|
||||
self.camera_stop_events: dict[str, MpEvent] = {}
|
||||
self.metrics_manager = metrics_manager
|
||||
|
||||
def __ensure_camera_stop_event(self, camera: str) -> MpEvent:
|
||||
camera_stop_event = self.camera_stop_events.get(camera)
|
||||
|
||||
if camera_stop_event is None:
|
||||
camera_stop_event = mp.Event()
|
||||
self.camera_stop_events[camera] = camera_stop_event
|
||||
else:
|
||||
camera_stop_event.clear()
|
||||
|
||||
return camera_stop_event
|
||||
|
||||
def __init_historical_regions(self) -> None:
|
||||
# delete region grids for removed or renamed cameras
|
||||
cameras = list(self.config.cameras.keys())
|
||||
@ -99,6 +111,8 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Camera processor not started for disabled camera {name}")
|
||||
return
|
||||
|
||||
camera_stop_event = self.__ensure_camera_stop_event(name)
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
|
||||
@ -135,7 +149,7 @@ class CameraMaintainer(threading.Thread):
|
||||
self.camera_metrics[name],
|
||||
self.ptz_metrics[name],
|
||||
self.region_grids[name],
|
||||
self.stop_event,
|
||||
camera_stop_event,
|
||||
self.config.logger,
|
||||
)
|
||||
self.camera_processes[config.name] = camera_process
|
||||
@ -150,6 +164,8 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Capture process not started for disabled camera {name}")
|
||||
return
|
||||
|
||||
camera_stop_event = self.__ensure_camera_stop_event(name)
|
||||
|
||||
# pre-create shms
|
||||
count = 10 if runtime else self.shm_count
|
||||
for i in range(count):
|
||||
@ -160,7 +176,7 @@ class CameraMaintainer(threading.Thread):
|
||||
config,
|
||||
count,
|
||||
self.camera_metrics[name],
|
||||
self.stop_event,
|
||||
camera_stop_event,
|
||||
self.config.logger,
|
||||
)
|
||||
capture_process.daemon = True
|
||||
@ -170,18 +186,36 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Capture process started for {name}: {capture_process.pid}")
|
||||
|
||||
def __stop_camera_capture_process(self, camera: str) -> None:
|
||||
capture_process = self.capture_processes[camera]
|
||||
capture_process = self.capture_processes.get(camera)
|
||||
if capture_process is not None:
|
||||
logger.info(f"Waiting for capture process for {camera} to stop")
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
camera_stop_event = self.camera_stop_events.get(camera)
|
||||
|
||||
if camera_stop_event is not None:
|
||||
camera_stop_event.set()
|
||||
|
||||
capture_process.join(timeout=10)
|
||||
if capture_process.is_alive():
|
||||
logger.warning(
|
||||
f"Capture process for {camera} didn't exit, forcing termination"
|
||||
)
|
||||
capture_process.terminate()
|
||||
capture_process.join()
|
||||
|
||||
def __stop_camera_process(self, camera: str) -> None:
|
||||
camera_process = self.camera_processes[camera]
|
||||
camera_process = self.camera_processes.get(camera)
|
||||
if camera_process is not None:
|
||||
logger.info(f"Waiting for process for {camera} to stop")
|
||||
camera_process.terminate()
|
||||
camera_process.join()
|
||||
camera_stop_event = self.camera_stop_events.get(camera)
|
||||
|
||||
if camera_stop_event is not None:
|
||||
camera_stop_event.set()
|
||||
|
||||
camera_process.join(timeout=10)
|
||||
if camera_process.is_alive():
|
||||
logger.warning(f"Process for {camera} didn't exit, forcing termination")
|
||||
camera_process.terminate()
|
||||
camera_process.join()
|
||||
logger.info(f"Closing frame queue for {camera}")
|
||||
empty_and_close_queue(self.camera_metrics[camera].frame_queue)
|
||||
|
||||
@ -199,6 +233,12 @@ class CameraMaintainer(threading.Thread):
|
||||
for update_type, updated_cameras in updates.items():
|
||||
if update_type == CameraConfigUpdateEnum.add.name:
|
||||
for camera in updated_cameras:
|
||||
if (
|
||||
camera in self.camera_processes
|
||||
or camera in self.capture_processes
|
||||
):
|
||||
continue
|
||||
|
||||
self.__start_camera_processor(
|
||||
camera,
|
||||
self.update_subscriber.camera_configs[camera],
|
||||
@ -210,15 +250,22 @@ class CameraMaintainer(threading.Thread):
|
||||
runtime=True,
|
||||
)
|
||||
elif update_type == CameraConfigUpdateEnum.remove.name:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
for camera in updated_cameras:
|
||||
self.__stop_camera_capture_process(camera)
|
||||
self.__stop_camera_process(camera)
|
||||
self.capture_processes.pop(camera, None)
|
||||
self.camera_processes.pop(camera, None)
|
||||
self.camera_stop_events.pop(camera, None)
|
||||
self.region_grids.pop(camera, None)
|
||||
self.camera_metrics.pop(camera, None)
|
||||
self.ptz_metrics.pop(camera, None)
|
||||
|
||||
# ensure the capture processes are done
|
||||
for camera in self.camera_processes.keys():
|
||||
for camera in self.capture_processes.keys():
|
||||
self.__stop_camera_capture_process(camera)
|
||||
|
||||
# ensure the camera processors are done
|
||||
for camera in self.capture_processes.keys():
|
||||
for camera in self.camera_processes.keys():
|
||||
self.__stop_camera_process(camera)
|
||||
|
||||
self.update_subscriber.stop()
|
||||
|
||||
@ -26,8 +26,8 @@ class ConfigPublisher:
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
|
||||
|
||||
class ConfigSubscriber:
|
||||
@ -55,5 +55,5 @@ class ConfigSubscriber:
|
||||
return (None, None)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
|
||||
@ -110,6 +110,9 @@ class Dispatcher:
|
||||
payload: str,
|
||||
sub_command: str | None = None,
|
||||
) -> None:
|
||||
if camera_name not in self.config.cameras:
|
||||
return
|
||||
|
||||
try:
|
||||
if command_type == "set":
|
||||
if sub_command:
|
||||
@ -131,6 +134,9 @@ class Dispatcher:
|
||||
|
||||
def handle_request_region_grid() -> Any:
|
||||
camera = payload
|
||||
if camera not in self.config.cameras:
|
||||
return None
|
||||
|
||||
grid = get_camera_regions_grid(
|
||||
camera,
|
||||
self.config.cameras[camera].detect,
|
||||
@ -243,7 +249,11 @@ class Dispatcher:
|
||||
self.publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()))
|
||||
|
||||
def handle_on_connect() -> None:
|
||||
camera_status = self.camera_activity.last_camera_activity.copy()
|
||||
camera_status = {
|
||||
camera: status
|
||||
for camera, status in self.camera_activity.last_camera_activity.copy().items()
|
||||
if camera in self.config.cameras
|
||||
}
|
||||
audio_detections = self.audio_activity.current_audio_detections.copy()
|
||||
cameras_with_status = camera_status.keys()
|
||||
|
||||
@ -346,7 +356,8 @@ class Dispatcher:
|
||||
# example /cam_name/notifications/suspend payload=duration
|
||||
camera_name = parts[-3]
|
||||
command = parts[-2]
|
||||
self._on_camera_notification_suspend(camera_name, payload)
|
||||
if camera_name in self.config.cameras:
|
||||
self._on_camera_notification_suspend(camera_name, payload)
|
||||
except IndexError:
|
||||
logger.error(
|
||||
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
||||
|
||||
@ -61,8 +61,8 @@ class InterProcessCommunicator(Communicator):
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
self.reader_thread.join()
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
|
||||
|
||||
class InterProcessRequestor:
|
||||
@ -82,5 +82,5 @@ class InterProcessRequestor:
|
||||
return ""
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
|
||||
@ -43,7 +43,7 @@ class ZmqProxy:
|
||||
|
||||
def stop(self) -> None:
|
||||
# destroying the context will tell the proxy to stop
|
||||
self.context.destroy()
|
||||
self.context.destroy(linger=0)
|
||||
self.runner.join()
|
||||
|
||||
|
||||
@ -66,8 +66,8 @@ class Publisher(Generic[T]):
|
||||
self.socket.send_string(f"{self.topic}{sub_topic} {json.dumps(payload)}")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
|
||||
|
||||
class Subscriber(Generic[T]):
|
||||
@ -96,8 +96,8 @@ class Subscriber(Generic[T]):
|
||||
return self._return_object("", None)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.socket.close()
|
||||
self.context.destroy()
|
||||
self.socket.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
|
||||
def _return_object(self, topic: str, payload: T | None) -> T | None:
|
||||
return payload
|
||||
|
||||
@ -80,8 +80,8 @@ class CameraConfigUpdateSubscriber:
|
||||
self.camera_configs[camera] = updated_config
|
||||
return
|
||||
elif update_type == CameraConfigUpdateEnum.remove:
|
||||
self.config.cameras.pop(camera)
|
||||
self.camera_configs.pop(camera)
|
||||
self.config.cameras.pop(camera, None)
|
||||
self.camera_configs.pop(camera, None)
|
||||
return
|
||||
|
||||
config = self.camera_configs.get(camera)
|
||||
|
||||
@ -14,6 +14,8 @@ RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||
TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
|
||||
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
|
||||
CACHE_DIR = "/tmp/cache"
|
||||
REPLAY_CAMERA_PREFIX = "_replay_"
|
||||
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
|
||||
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||
PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
|
||||
443
frigate/debug_replay.py
Normal file
443
frigate/debug_replay.py
Normal file
@ -0,0 +1,443 @@
|
||||
"""Debug replay camera management for replaying recordings with detection overlays."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
import threading
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import (
|
||||
CLIPS_DIR,
|
||||
RECORD_DIR,
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Manages a single debug replay session."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.replay_camera_name: str | None = None
|
||||
self.source_camera: str | None = None
|
||||
self.clip_path: str | None = None
|
||||
self.start_ts: float | None = None
|
||||
self.end_ts: float | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether a replay session is currently active."""
|
||||
return self.replay_camera_name is not None
|
||||
|
||||
def start(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
"""Start a debug replay session.
|
||||
|
||||
Args:
|
||||
source_camera: Name of the source camera to replay
|
||||
start_ts: Start timestamp
|
||||
end_ts: End timestamp
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
|
||||
Returns:
|
||||
The replay camera name
|
||||
|
||||
Raises:
|
||||
ValueError: If a session is already active or parameters are invalid
|
||||
RuntimeError: If clip generation fails
|
||||
"""
|
||||
with self._lock:
|
||||
return self._start_locked(
|
||||
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
||||
)
|
||||
|
||||
def _start_locked(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
if self.active:
|
||||
raise ValueError("A replay session is already active")
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
# Query recordings for the source camera in the time range
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(start_ts, end_ts)
|
||||
| Recordings.end_time.between(start_ts, end_ts)
|
||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
||||
)
|
||||
.where(Recordings.camera == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
# Create replay directory
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
|
||||
# Generate replay camera name
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
|
||||
# Build concat file for ffmpeg
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
|
||||
# Concatenate recordings into a single clip with -c copy (fast)
|
||||
ffmpeg_cmd = [
|
||||
frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
clip_path,
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Generating replay clip for %s (%.1f - %.1f)",
|
||||
source_camera,
|
||||
start_ts,
|
||||
end_ts,
|
||||
)
|
||||
|
||||
try:
|
||||
result = sp.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("FFmpeg error: %s", result.stderr)
|
||||
raise RuntimeError(
|
||||
f"Failed to generate replay clip: {result.stderr[-500:]}"
|
||||
)
|
||||
except sp.TimeoutExpired:
|
||||
raise RuntimeError("Clip generation timed out")
|
||||
finally:
|
||||
# Clean up concat file
|
||||
if os.path.exists(concat_file):
|
||||
os.remove(concat_file)
|
||||
|
||||
if not os.path.exists(clip_path):
|
||||
raise RuntimeError("Clip file was not created")
|
||||
|
||||
# Build camera config dict for the replay camera
|
||||
source_config = frigate_config.cameras[source_camera]
|
||||
camera_dict = self._build_camera_config_dict(
|
||||
source_config, replay_name, clip_path
|
||||
)
|
||||
|
||||
# Build an in-memory config with the replay camera added
|
||||
config_file = find_config_file()
|
||||
yaml_parser = YAML()
|
||||
with open(config_file, "r") as f:
|
||||
config_data = yaml_parser.load(f)
|
||||
|
||||
if "cameras" not in config_data or config_data["cameras"] is None:
|
||||
config_data["cameras"] = {}
|
||||
config_data["cameras"][replay_name] = camera_dict
|
||||
|
||||
try:
|
||||
new_config = FrigateConfig.parse_object(config_data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
||||
|
||||
# Update the running config
|
||||
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
||||
|
||||
# Publish the add event
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
||||
new_config.cameras[replay_name],
|
||||
)
|
||||
|
||||
# Store session state
|
||||
self.replay_camera_name = replay_name
|
||||
self.source_camera = source_camera
|
||||
self.clip_path = clip_path
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
|
||||
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
||||
return replay_name
|
||||
|
||||
def stop(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Stop the active replay session and clean up all artifacts.
|
||||
|
||||
Args:
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
"""
|
||||
with self._lock:
|
||||
self._stop_locked(frigate_config, config_publisher)
|
||||
|
||||
def _stop_locked(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
|
||||
# Publish remove event so subscribers stop and remove from their config
|
||||
if replay_name in frigate_config.cameras:
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
# Do NOT pop here — let subscribers handle removal from the shared
|
||||
# config dict when they process the ZMQ message to avoid race conditions
|
||||
|
||||
# Defensive DB cleanup
|
||||
self._cleanup_db(replay_name)
|
||||
|
||||
# Remove filesystem artifacts
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
# Reset state
|
||||
self.replay_camera_name = None
|
||||
self.source_camera = None
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
def _build_camera_config_dict(
|
||||
self,
|
||||
source_config,
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
) -> dict:
|
||||
"""Build a camera config dictionary for the replay camera.
|
||||
|
||||
Args:
|
||||
source_config: Source camera's CameraConfig
|
||||
replay_name: Name for the replay camera
|
||||
clip_path: Path to the replay clip file
|
||||
|
||||
Returns:
|
||||
Camera config as a dictionary
|
||||
"""
|
||||
# Extract detect config (exclude computed fields)
|
||||
detect_dict = source_config.detect.model_dump(
|
||||
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
||||
)
|
||||
|
||||
# Extract objects config, using .dict() on filters to convert
|
||||
# RuntimeFilterConfig ndarray masks back to string coordinates
|
||||
objects_dict = {
|
||||
"track": source_config.objects.track,
|
||||
"mask": {
|
||||
mask_id: (
|
||||
mask_cfg.model_dump(
|
||||
exclude={"raw_coordinates", "enabled_in_config"}
|
||||
)
|
||||
if mask_cfg is not None
|
||||
else None
|
||||
)
|
||||
for mask_id, mask_cfg in source_config.objects.mask.items()
|
||||
}
|
||||
if source_config.objects.mask
|
||||
else {},
|
||||
"filters": {
|
||||
name: filt.dict() if hasattr(filt, "dict") else filt.model_dump()
|
||||
for name, filt in source_config.objects.filters.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Extract zones (exclude_defaults avoids serializing empty defaults
|
||||
# like distances=[] that fail validation on re-parse)
|
||||
zones_dict = {}
|
||||
for zone_name, zone_config in source_config.zones.items():
|
||||
zone_dump = zone_config.model_dump(
|
||||
exclude={"contour", "color"}, exclude_defaults=True
|
||||
)
|
||||
# Always include required fields
|
||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||
zones_dict[zone_name] = zone_dump
|
||||
|
||||
# Extract motion config (exclude runtime fields)
|
||||
motion_dict = {}
|
||||
if source_config.motion is not None:
|
||||
motion_dict = source_config.motion.model_dump(
|
||||
exclude={
|
||||
"frame_shape",
|
||||
"raw_mask",
|
||||
"mask",
|
||||
"improved_contrast_enabled",
|
||||
"rasterized_mask",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": clip_path,
|
||||
"roles": ["detect"],
|
||||
"input_args": "-re -stream_loop -1 -fflags +genpts",
|
||||
}
|
||||
],
|
||||
"hwaccel_args": [],
|
||||
},
|
||||
"detect": detect_dict,
|
||||
"objects": objects_dict,
|
||||
"zones": zones_dict,
|
||||
"motion": motion_dict,
|
||||
"record": {"enabled": False},
|
||||
"snapshots": {"enabled": False},
|
||||
"review": {
|
||||
"alerts": {"enabled": False},
|
||||
"detections": {"enabled": False},
|
||||
},
|
||||
"birdseye": {"enabled": False},
|
||||
"audio": {"enabled": False},
|
||||
"lpr": {"enabled": False},
|
||||
"face_recognition": {"enabled": False},
|
||||
}
|
||||
|
||||
def _cleanup_db(self, camera_name: str) -> None:
|
||||
"""Defensively remove any database rows for the replay camera."""
|
||||
try:
|
||||
Event.delete().where(Event.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay events: %s", e)
|
||||
|
||||
try:
|
||||
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay timeline: %s", e)
|
||||
|
||||
try:
|
||||
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay recordings: %s", e)
|
||||
|
||||
try:
|
||||
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete replay review segments: %s", e)
|
||||
|
||||
def _cleanup_files(self, camera_name: str) -> None:
|
||||
"""Remove filesystem artifacts for the replay camera."""
|
||||
dirs_to_clean = [
|
||||
os.path.join(RECORD_DIR, camera_name),
|
||||
os.path.join(CLIPS_DIR, camera_name),
|
||||
os.path.join(THUMB_DIR, camera_name),
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_clean:
|
||||
if os.path.exists(dir_path):
|
||||
try:
|
||||
shutil.rmtree(dir_path)
|
||||
logger.debug("Removed replay directory: %s", dir_path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove %s: %s", dir_path, e)
|
||||
|
||||
# Remove replay clip and any related files
|
||||
if os.path.exists(REPLAY_DIR):
|
||||
try:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
logger.debug("Removed replay cache directory")
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove replay cache: %s", e)
|
||||
|
||||
|
||||
def cleanup_replay_cameras() -> None:
|
||||
"""Remove any stale replay camera artifacts on startup.
|
||||
|
||||
Since replay cameras are memory-only and never written to YAML, they
|
||||
won't appear in the config after a restart. This function cleans up
|
||||
filesystem and database artifacts from any replay that was running when
|
||||
the process stopped.
|
||||
|
||||
Must be called AFTER the database is bound.
|
||||
"""
|
||||
stale_cameras: set[str] = set()
|
||||
|
||||
# Scan filesystem for leftover replay artifacts to derive camera names
|
||||
for dir_path in [RECORD_DIR, CLIPS_DIR, THUMB_DIR]:
|
||||
if os.path.isdir(dir_path):
|
||||
for entry in os.listdir(dir_path):
|
||||
if entry.startswith(REPLAY_CAMERA_PREFIX):
|
||||
stale_cameras.add(entry)
|
||||
|
||||
if os.path.isdir(REPLAY_DIR):
|
||||
for entry in os.listdir(REPLAY_DIR):
|
||||
if entry.startswith(REPLAY_CAMERA_PREFIX) and entry.endswith(".mp4"):
|
||||
stale_cameras.add(entry.removesuffix(".mp4"))
|
||||
|
||||
if not stale_cameras:
|
||||
return
|
||||
|
||||
logger.info("Cleaning up stale replay camera artifacts: %s", list(stale_cameras))
|
||||
|
||||
manager = DebugReplayManager()
|
||||
for camera_name in stale_cameras:
|
||||
manager._cleanup_db(camera_name)
|
||||
manager._cleanup_files(camera_name)
|
||||
|
||||
if os.path.exists(REPLAY_DIR):
|
||||
try:
|
||||
shutil.rmtree(REPLAY_DIR)
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove replay cache directory: %s", e)
|
||||
@ -421,7 +421,9 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if self.config.semantic_search.enabled:
|
||||
self.embeddings.update_stats()
|
||||
|
||||
camera_config = self.config.cameras[camera]
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
# no need to process updated objects if no processors are active
|
||||
if len(self.realtime_processors) == 0 and len(self.post_processors) == 0:
|
||||
@ -639,7 +641,10 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if not camera or camera not in self.config.cameras:
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras[camera]
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
dedicated_lpr_enabled = (
|
||||
camera_config.type == CameraTypeEnum.lpr
|
||||
and "license_plate" not in camera_config.objects.track
|
||||
|
||||
@ -7,6 +7,7 @@ from typing import Dict
|
||||
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import ObjectClassificationType
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Event
|
||||
from frigate.util.builtin import to_relative_box
|
||||
@ -146,7 +147,9 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
if should_update_db(self.events_in_process[event_data["id"]], event_data):
|
||||
updated_db = True
|
||||
camera_config = self.config.cameras[camera]
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
width = camera_config.detect.width
|
||||
height = camera_config.detect.height
|
||||
first_detector = list(self.config.detectors.values())[0]
|
||||
@ -283,6 +286,10 @@ class EventProcessor(threading.Thread):
|
||||
def handle_external_detection(
|
||||
self, event_type: EventStateEnum, event_data: Event
|
||||
) -> None:
|
||||
# Skip replay cameras
|
||||
if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX):
|
||||
return
|
||||
|
||||
if event_type == EventStateEnum.start:
|
||||
event = {
|
||||
Event.id: event_data["id"],
|
||||
|
||||
@ -222,27 +222,24 @@ class OllamaClient(GenAIClient):
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
final_message: dict[str, Any] | None = None
|
||||
try:
|
||||
stream = await async_client.chat(**request_params)
|
||||
async for chunk in stream:
|
||||
if not chunk or "message" not in chunk:
|
||||
continue
|
||||
msg = chunk.get("message", {})
|
||||
delta = msg.get("content") or ""
|
||||
if delta:
|
||||
content_parts.append(delta)
|
||||
yield ("content_delta", delta)
|
||||
if chunk.get("done"):
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
tool_calls = parse_tool_calls_from_message(msg)
|
||||
final_message = {
|
||||
"content": full_content,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": "tool_calls" if tool_calls else "stop",
|
||||
}
|
||||
break
|
||||
finally:
|
||||
await async_client.close()
|
||||
stream = await async_client.chat(**request_params)
|
||||
async for chunk in stream:
|
||||
if not chunk or "message" not in chunk:
|
||||
continue
|
||||
msg = chunk.get("message", {})
|
||||
delta = msg.get("content") or ""
|
||||
if delta:
|
||||
content_parts.append(delta)
|
||||
yield ("content_delta", delta)
|
||||
if chunk.get("done"):
|
||||
full_content = "".join(content_parts).strip() or None
|
||||
tool_calls = parse_tool_calls_from_message(msg)
|
||||
final_message = {
|
||||
"content": full_content,
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": "tool_calls" if tool_calls else "stop",
|
||||
}
|
||||
break
|
||||
|
||||
if final_message is not None:
|
||||
yield ("message", final_message)
|
||||
|
||||
@ -420,7 +420,8 @@ class BirdsEyeFrameManager:
|
||||
[
|
||||
cam
|
||||
for cam, cam_data in self.cameras.items()
|
||||
if self.config.cameras[cam].birdseye.enabled
|
||||
if cam in self.config.cameras
|
||||
and self.config.cameras[cam].birdseye.enabled
|
||||
and self.config.cameras[cam].enabled_in_config
|
||||
and self.config.cameras[cam].enabled
|
||||
and cam_data["last_active_frame"] > 0
|
||||
@ -723,8 +724,11 @@ class BirdsEyeFrameManager:
|
||||
Update birdseye for a specific camera with new frame data.
|
||||
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
|
||||
"""
|
||||
# don't process if birdseye is disabled for this camera
|
||||
camera_config = self.config.cameras[camera]
|
||||
# don't process if camera was removed or birdseye is disabled
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False, False
|
||||
|
||||
force_update = False
|
||||
|
||||
# disabling birdseye is a little tricky
|
||||
|
||||
@ -22,7 +22,12 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, PROCESS_PRIORITY_MED
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
PROCESS_PRIORITY_MED,
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
)
|
||||
from frigate.output.birdseye import Birdseye
|
||||
from frigate.output.camera import JsmpegCamera
|
||||
from frigate.output.preview import PreviewRecorder
|
||||
@ -79,6 +84,32 @@ class OutputProcess(FrigateProcess):
|
||||
)
|
||||
self.config = config
|
||||
|
||||
def is_debug_replay_camera(self, camera: str) -> bool:
|
||||
return camera.startswith(REPLAY_CAMERA_PREFIX)
|
||||
|
||||
def add_camera(
|
||||
self,
|
||||
camera: str,
|
||||
websocket_server: WSGIServer,
|
||||
jsmpeg_cameras: dict[str, JsmpegCamera],
|
||||
preview_recorders: dict[str, PreviewRecorder],
|
||||
preview_write_times: dict[str, float],
|
||||
birdseye: Birdseye | None,
|
||||
) -> None:
|
||||
camera_config = self.config.cameras[camera]
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
camera_config, self.stop_event, websocket_server
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(camera_config)
|
||||
preview_write_times[camera] = 0
|
||||
|
||||
if (
|
||||
birdseye is not None
|
||||
and self.config.birdseye.enabled
|
||||
and camera_config.birdseye.enabled
|
||||
):
|
||||
birdseye.add_camera(camera)
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup(self.config.logger)
|
||||
|
||||
@ -118,14 +149,17 @@ class OutputProcess(FrigateProcess):
|
||||
move_preview_frames("cache")
|
||||
|
||||
for camera, cam_config in self.config.cameras.items():
|
||||
if not cam_config.enabled_in_config:
|
||||
if not cam_config.enabled_in_config or self.is_debug_replay_camera(camera):
|
||||
continue
|
||||
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
cam_config, self.stop_event, websocket_server
|
||||
self.add_camera(
|
||||
camera,
|
||||
websocket_server,
|
||||
jsmpeg_cameras,
|
||||
preview_recorders,
|
||||
preview_write_times,
|
||||
birdseye,
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(cam_config)
|
||||
preview_write_times[camera] = 0
|
||||
|
||||
if self.config.birdseye.enabled:
|
||||
birdseye = Birdseye(self.config, self.stop_event, websocket_server)
|
||||
@ -138,19 +172,15 @@ class OutputProcess(FrigateProcess):
|
||||
|
||||
if CameraConfigUpdateEnum.add in updates:
|
||||
for camera in updates["add"]:
|
||||
jsmpeg_cameras[camera] = JsmpegCamera(
|
||||
self.config.cameras[camera], self.stop_event, websocket_server
|
||||
)
|
||||
preview_recorders[camera] = PreviewRecorder(
|
||||
self.config.cameras[camera]
|
||||
)
|
||||
preview_write_times[camera] = 0
|
||||
|
||||
if (
|
||||
self.config.birdseye.enabled
|
||||
and self.config.cameras[camera].birdseye.enabled
|
||||
):
|
||||
birdseye.add_camera(camera)
|
||||
if not self.is_debug_replay_camera(camera):
|
||||
self.add_camera(
|
||||
camera,
|
||||
websocket_server,
|
||||
jsmpeg_cameras,
|
||||
preview_recorders,
|
||||
preview_write_times,
|
||||
birdseye,
|
||||
)
|
||||
|
||||
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
||||
now = datetime.datetime.now().timestamp()
|
||||
@ -174,7 +204,11 @@ class OutputProcess(FrigateProcess):
|
||||
_,
|
||||
) = data
|
||||
|
||||
if not self.config.cameras[camera].enabled:
|
||||
if (
|
||||
camera not in self.config.cameras
|
||||
or not self.config.cameras[camera].enabled
|
||||
or self.is_debug_replay_camera(camera)
|
||||
):
|
||||
continue
|
||||
|
||||
frame = frame_manager.get(
|
||||
|
||||
@ -287,11 +287,12 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
# publish most recently available recording time and None if disabled
|
||||
camera_cfg = self.config.cameras.get(camera)
|
||||
self.recordings_publisher.publish(
|
||||
(
|
||||
camera,
|
||||
recordings[0]["start_time"].timestamp()
|
||||
if self.config.cameras[camera].record.enabled
|
||||
if camera_cfg and camera_cfg.record.enabled
|
||||
else None,
|
||||
None,
|
||||
),
|
||||
@ -315,9 +316,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
) -> Optional[Recordings]:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
# Just delete files if recordings are turned off
|
||||
# Just delete files if camera removed or recordings are turned off
|
||||
if (
|
||||
camera not in self.config.cameras
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
|
||||
@ -652,6 +652,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if camera not in self.indefinite_events:
|
||||
self.indefinite_events[camera] = {}
|
||||
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
if (
|
||||
not self.config.cameras[camera].enabled
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
|
||||
@ -340,6 +340,9 @@ def stats_snapshot(
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
if name not in config.cameras:
|
||||
continue
|
||||
|
||||
total_camera_fps += camera_stats.camera_fps.value
|
||||
total_process_fps += camera_stats.process_fps.value
|
||||
total_skipped_fps += camera_stats.skipped_fps.value
|
||||
|
||||
@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from peewee import SQL, fn
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.const import RECORD_DIR, REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.util.builtin import clear_and_unlink
|
||||
|
||||
@ -32,6 +32,10 @@ class StorageMaintainer(threading.Thread):
|
||||
def calculate_camera_bandwidth(self) -> None:
|
||||
"""Calculate an average MB/hr for each camera."""
|
||||
for camera in self.config.cameras.keys():
|
||||
# Skip replay cameras
|
||||
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
# cameras with < 50 segments should be refreshed to keep size accurate
|
||||
# when few segments are available
|
||||
if self.camera_storage_stats.get(camera, {}).get("needs_refresh", True):
|
||||
@ -77,6 +81,10 @@ class StorageMaintainer(threading.Thread):
|
||||
usages: dict[str, dict] = {}
|
||||
|
||||
for camera in self.config.cameras.keys():
|
||||
# Skip replay cameras
|
||||
if camera.startswith(REPLAY_CAMERA_PREFIX):
|
||||
continue
|
||||
|
||||
camera_storage = (
|
||||
Recordings.select(fn.SUM(Recordings.segment_size))
|
||||
.where(Recordings.camera == camera, Recordings.segment_size != 0)
|
||||
|
||||
@ -13,6 +13,7 @@ from pydantic import Json
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import BASE_DIR, CACHE_DIR
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
@ -141,6 +142,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
stats,
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
|
||||
@ -22,3 +22,32 @@ class TestHttpApp(BaseTestHttp):
|
||||
response = client.get("/stats")
|
||||
response_json = response.json()
|
||||
assert response_json == self.test_stats
|
||||
|
||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||
"track": ["person", "car"],
|
||||
}
|
||||
app = super().create_app()
|
||||
app.config_publisher = Mock()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"requires_restart": 0,
|
||||
"skip_save": True,
|
||||
"update_topic": "config/cameras/front_door/objects",
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"objects": {
|
||||
"track": ["person"],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
@ -151,6 +151,22 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert "dog" in frigate_config.cameras["back"].objects.track
|
||||
|
||||
def test_deep_merge_override_replaces_list_values(self):
|
||||
base = {"objects": {"track": ["person", "face"]}}
|
||||
update = {"objects": {"track": ["person"]}}
|
||||
|
||||
merged = deep_merge(base, update, override=True)
|
||||
|
||||
assert merged["objects"]["track"] == ["person"]
|
||||
|
||||
def test_deep_merge_merge_lists_still_appends(self):
|
||||
base = {"track": ["person"]}
|
||||
update = {"track": ["face"]}
|
||||
|
||||
merged = deep_merge(base, update, override=True, merge_lists=True)
|
||||
|
||||
assert merged["track"] == ["person", "face"]
|
||||
|
||||
def test_override_birdseye(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
|
||||
@ -86,7 +86,9 @@ class TimelineProcessor(threading.Thread):
|
||||
event_data: dict[Any, Any],
|
||||
) -> bool:
|
||||
"""Handle object detection."""
|
||||
camera_config = self.config.cameras[camera]
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
event_id = event_data["id"]
|
||||
|
||||
# Base timeline entry data that all entries will share
|
||||
|
||||
@ -690,9 +690,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.create_camera_state(camera)
|
||||
elif "remove" in updated_topics:
|
||||
for camera in updated_topics["remove"]:
|
||||
camera_state = self.camera_states[camera]
|
||||
camera_state.shutdown()
|
||||
removed_camera_state = self.camera_states[camera]
|
||||
removed_camera_state.shutdown()
|
||||
self.camera_states.pop(camera)
|
||||
self.camera_activity.pop(camera, None)
|
||||
self.last_motion_detected.pop(camera, None)
|
||||
|
||||
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
|
||||
|
||||
# manage camera disabled state
|
||||
for camera, config in self.config.cameras.items():
|
||||
@ -700,6 +704,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
continue
|
||||
|
||||
current_enabled = config.enabled
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state = self.camera_states[camera]
|
||||
|
||||
if camera_state.prev_enabled and not current_enabled:
|
||||
@ -752,7 +760,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
if not self.config.cameras[camera].enabled:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
if not camera_config.enabled:
|
||||
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||
continue
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ from frigate.config import (
|
||||
SnapshotsConfig,
|
||||
UIConfig,
|
||||
)
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.const import CLIPS_DIR, REPLAY_CAMERA_PREFIX, THUMB_DIR
|
||||
from frigate.detectors.detector_config import ModelConfig
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.builtin import sanitize_float
|
||||
@ -621,6 +621,9 @@ class TrackedObject:
|
||||
if not self.camera_config.name:
|
||||
return
|
||||
|
||||
if self.camera_config.name.startswith(REPLAY_CAMERA_PREFIX):
|
||||
return
|
||||
|
||||
directory = os.path.join(THUMB_DIR, self.camera_config.name)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
|
||||
@ -84,7 +84,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
"""
|
||||
:param dct1: First dict to merge
|
||||
:param dct2: Second dict to merge
|
||||
:param override: if same key exists in both dictionaries, should override? otherwise ignore. (default=True)
|
||||
:param override: if same key exists in both dictionaries, should override? otherwise ignore.
|
||||
:param merge_lists: if True, lists will be merged.
|
||||
:return: The merge dictionary
|
||||
"""
|
||||
merged = copy.deepcopy(dct1)
|
||||
@ -96,6 +97,8 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
elif isinstance(v1, list) and isinstance(v2, list):
|
||||
if merge_lists:
|
||||
merged[k] = v1 + v2
|
||||
elif override:
|
||||
merged[k] = copy.deepcopy(v2)
|
||||
else:
|
||||
if override:
|
||||
merged[k] = copy.deepcopy(v2)
|
||||
|
||||
@ -9,6 +9,7 @@ from typing import Any, Optional, Union
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -688,3 +689,78 @@ class StreamInfoRetriever:
|
||||
info = asyncio.run(get_video_properties(ffmpeg, path))
|
||||
self.stream_cache[path] = info
|
||||
return info
|
||||
|
||||
|
||||
def apply_section_update(camera_config, section: str, update: dict) -> Optional[str]:
|
||||
"""Merge an update dict into a camera config section and rebuild runtime variants.
|
||||
|
||||
For motion and object filter sections, the plain Pydantic models are rebuilt
|
||||
as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks
|
||||
are recomputed. This mirrors the logic in FrigateConfig.post_validation.
|
||||
|
||||
Args:
|
||||
camera_config: The CameraConfig instance to update.
|
||||
section: Config section name (e.g. "motion", "objects").
|
||||
update: Nested dict of field updates to merge.
|
||||
|
||||
Returns:
|
||||
None on success, or an error message string on failure.
|
||||
"""
|
||||
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
|
||||
|
||||
current = getattr(camera_config, section, None)
|
||||
if current is None:
|
||||
return f"Section '{section}' not found on camera '{camera_config.name}'"
|
||||
|
||||
try:
|
||||
frame_shape = camera_config.frame_shape
|
||||
|
||||
if section == "motion":
|
||||
merged = deep_merge(
|
||||
current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}),
|
||||
update,
|
||||
override=True,
|
||||
)
|
||||
camera_config.motion = RuntimeMotionConfig(
|
||||
frame_shape=frame_shape, **merged
|
||||
)
|
||||
|
||||
elif section == "objects":
|
||||
merged = deep_merge(
|
||||
current.model_dump(
|
||||
exclude={"filters": {"__all__": {"rasterized_mask"}}}
|
||||
),
|
||||
update,
|
||||
override=True,
|
||||
)
|
||||
new_objects = current.__class__.model_validate(merged)
|
||||
|
||||
# Preserve private _all_objects from original config
|
||||
try:
|
||||
new_objects._all_objects = current._all_objects
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Rebuild RuntimeFilterConfig with merged global + per-object masks
|
||||
for obj_name, filt in new_objects.filters.items():
|
||||
merged_mask = dict(filt.mask)
|
||||
if new_objects.mask:
|
||||
for gid, gmask in new_objects.mask.items():
|
||||
merged_mask[f"global_{gid}"] = gmask
|
||||
|
||||
new_objects.filters[obj_name] = RuntimeFilterConfig(
|
||||
frame_shape=frame_shape,
|
||||
mask=merged_mask,
|
||||
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
|
||||
)
|
||||
camera_config.objects = new_objects
|
||||
|
||||
else:
|
||||
merged = deep_merge(current.model_dump(), update, override=True)
|
||||
setattr(camera_config, section, current.__class__.model_validate(merged))
|
||||
|
||||
except Exception:
|
||||
logger.exception("Config validation error")
|
||||
return "Validation error. Check logs for details."
|
||||
|
||||
return None
|
||||
|
||||
@ -117,6 +117,7 @@
|
||||
"button": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
"applying": "Applying…",
|
||||
"reset": "Reset",
|
||||
"undo": "Undo",
|
||||
"done": "Done",
|
||||
@ -252,6 +253,7 @@
|
||||
"review": "Review",
|
||||
"explore": "Explore",
|
||||
"export": "Export",
|
||||
"actions": "Actions",
|
||||
"uiPlayground": "UI Playground",
|
||||
"faceLibrary": "Face Library",
|
||||
"classification": "Classification",
|
||||
|
||||
@ -216,6 +216,10 @@
|
||||
},
|
||||
"hideObjectDetails": {
|
||||
"label": "Hide object path"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "Debug replay",
|
||||
"aria": "View this tracked object in the debug replay view"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
54
web/public/locales/en/views/replay.json
Normal file
54
web/public/locales/en/views/replay.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"title": "Debug Replay",
|
||||
"description": "Replay camera recordings for debugging. The object list shows a time-delayed summary of detected objects and the Messages tab shows a stream of Frigate's internal messages from the replay footage.",
|
||||
"websocket_messages": "Messages",
|
||||
"dialog": {
|
||||
"title": "Start Debug Replay",
|
||||
"description": "Create a temporary replay camera that loops historical footage for debugging object detection and tracking issues. The replay camera will have the same detection configuration as the source camera. Choose a time range to begin.",
|
||||
"camera": "Source Camera",
|
||||
"timeRange": "Time Range",
|
||||
"preset": {
|
||||
"1m": "Last 1 Minute",
|
||||
"5m": "Last 5 Minutes",
|
||||
"timeline": "From Timeline",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"startButton": "Start Replay",
|
||||
"selectFromTimeline": "Select",
|
||||
"starting": "Starting replay...",
|
||||
"startLabel": "Start",
|
||||
"endLabel": "End",
|
||||
"toast": {
|
||||
"success": "Debug replay started successfully",
|
||||
"error": "Failed to start debug replay: {{error}}",
|
||||
"alreadyActive": "A replay session is already active",
|
||||
"stopped": "Debug replay stopped",
|
||||
"stopError": "Failed to stop debug replay: {{error}}",
|
||||
"goToReplay": "Go to Replay"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"noSession": "No Active Replay Session",
|
||||
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
|
||||
"goToRecordings": "Go to History",
|
||||
"sourceCamera": "Source Camera",
|
||||
"replayCamera": "Replay Camera",
|
||||
"initializingReplay": "Initializing replay...",
|
||||
"stoppingReplay": "Stopping replay...",
|
||||
"stopReplay": "Stop Replay",
|
||||
"confirmStop": {
|
||||
"title": "Stop Debug Replay?",
|
||||
"description": "This will stop the replay session and clean up all temporary data. Are you sure?",
|
||||
"confirm": "Stop Replay",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"activity": "Activity",
|
||||
"objects": "Object List",
|
||||
"audioDetections": "Audio Detections",
|
||||
"noActivity": "No activity detected",
|
||||
"activeTracking": "Active tracking",
|
||||
"noActiveTracking": "No active tracking",
|
||||
"configuration": "Configuration",
|
||||
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
|
||||
}
|
||||
}
|
||||
@ -1392,6 +1392,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"success": "Settings saved successfully",
|
||||
"applied": "Settings applied successfully",
|
||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||
"error": "Failed to save settings",
|
||||
"validationError": "Validation failed: {{message}}",
|
||||
|
||||
@ -7,12 +7,39 @@
|
||||
"logs": {
|
||||
"frigate": "Frigate Logs - Frigate",
|
||||
"go2rtc": "Go2RTC Logs - Frigate",
|
||||
"nginx": "Nginx Logs - Frigate"
|
||||
"nginx": "Nginx Logs - Frigate",
|
||||
"websocket": "Messages Logs - Frigate"
|
||||
}
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"logs": {
|
||||
"websocket": {
|
||||
"label": "Messages",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"clear": "Clear",
|
||||
"filter": {
|
||||
"all": "All topics",
|
||||
"topics": "Topics",
|
||||
"events": "Events",
|
||||
"reviews": "Reviews",
|
||||
"classification": "Classification",
|
||||
"face_recognition": "Face Recognition",
|
||||
"lpr": "LPR",
|
||||
"camera_activity": "Camera activity",
|
||||
"system": "System",
|
||||
"camera": "Camera",
|
||||
"all_cameras": "All cameras",
|
||||
"cameras_count_one": "{{count}} Camera",
|
||||
"cameras_count_other": "{{count}} Cameras"
|
||||
},
|
||||
"empty": "No messages captured yet",
|
||||
"count": "{{count}} messages",
|
||||
"expanded": {
|
||||
"payload": "Payload"
|
||||
}
|
||||
},
|
||||
"download": {
|
||||
"label": "Download Logs"
|
||||
},
|
||||
@ -189,7 +216,8 @@
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
||||
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB."
|
||||
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB.",
|
||||
"debugReplayActive": "Debug replay session is active"
|
||||
},
|
||||
"enrichments": {
|
||||
"title": "Enrichments",
|
||||
|
||||
@ -30,6 +30,7 @@ const Classification = lazy(() => import("@/pages/ClassificationModel"));
|
||||
const Chat = lazy(() => import("@/pages/Chat"));
|
||||
const Logs = lazy(() => import("@/pages/Logs"));
|
||||
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
|
||||
const Replay = lazy(() => import("@/pages/Replay"));
|
||||
|
||||
function App() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@ -108,7 +109,8 @@ function DefaultAppView() {
|
||||
<Route path="/faces" element={<FaceLibrary />} />
|
||||
<Route path="/classification" element={<Classification />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/playground" element={<UIPlayground />} />
|
||||
<Route path="/playground" element={<UIPlayground />} />{" "}
|
||||
<Route path="/replay" element={<Replay />} />{" "}
|
||||
</Route>
|
||||
<Route path="/unauthorized" element={<AccessDenied />} />
|
||||
<Route path="*" element={<Redirect to="/" />} />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import {
|
||||
EmbeddingsReindexProgressType,
|
||||
@ -17,6 +17,13 @@ import { FrigateStats } from "@/types/stats";
|
||||
import { createContainer } from "react-tracked";
|
||||
import useDeepMemo from "@/hooks/use-deep-memo";
|
||||
|
||||
export type WsFeedMessage = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Update = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
@ -29,6 +36,9 @@ type WsState = {
|
||||
|
||||
type useValueReturn = [WsState, (update: Update) => void];
|
||||
|
||||
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
||||
let wsMessageIdCounter = 0;
|
||||
|
||||
function useValue(): useValueReturn {
|
||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||
|
||||
@ -43,8 +53,13 @@ function useValue(): useValueReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraActivity: { [key: string]: FrigateCameraState } =
|
||||
JSON.parse(activityValue);
|
||||
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
|
||||
|
||||
try {
|
||||
cameraActivity = JSON.parse(activityValue);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(cameraActivity).length === 0) {
|
||||
return;
|
||||
@ -53,6 +68,12 @@ function useValue(): useValueReturn {
|
||||
const cameraStates: WsState = {};
|
||||
|
||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
||||
const cameraConfig = state?.config;
|
||||
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
record,
|
||||
detect,
|
||||
@ -67,7 +88,7 @@ function useValue(): useValueReturn {
|
||||
detections,
|
||||
object_descriptions,
|
||||
review_descriptions,
|
||||
} = state["config"];
|
||||
} = cameraConfig;
|
||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||
@ -115,6 +136,17 @@ function useValue(): useValueReturn {
|
||||
...prevState,
|
||||
[data.topic]: data.payload,
|
||||
}));
|
||||
|
||||
// Notify feed subscribers
|
||||
if (wsMessageSubscribers.size > 0) {
|
||||
const feedMsg: WsFeedMessage = {
|
||||
topic: data.topic,
|
||||
payload: data.payload,
|
||||
timestamp: Date.now(),
|
||||
id: String(wsMessageIdCounter++),
|
||||
};
|
||||
wsMessageSubscribers.forEach((cb) => cb(feedMsg));
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
@ -740,3 +772,16 @@ export function useJobStatus(
|
||||
|
||||
return { payload: currentJob as Job | null };
|
||||
}
|
||||
|
||||
export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (msg: WsFeedMessage) => callbackRef.current(msg);
|
||||
wsMessageSubscribers.add(handler);
|
||||
return () => {
|
||||
wsMessageSubscribers.delete(handler);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@ -26,7 +26,8 @@ export default function CameraImage({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
const { name } = config ? config.cameras[camera] : "";
|
||||
const cameraConfig = config?.cameras?.[camera];
|
||||
const { name } = cameraConfig ?? { name: camera };
|
||||
const { payload: enabledState } = useEnabledState(camera);
|
||||
const enabled = enabledState ? enabledState === "ON" : true;
|
||||
|
||||
@ -34,15 +35,15 @@ export default function CameraImage({
|
||||
useResizeObserver(containerRef);
|
||||
|
||||
const requestHeight = useMemo(() => {
|
||||
if (!config || containerHeight == 0) {
|
||||
if (!cameraConfig || containerHeight == 0) {
|
||||
return 360;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
config.cameras[camera].detect.height,
|
||||
cameraConfig.detect.height,
|
||||
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
|
||||
);
|
||||
}, [config, camera, containerHeight]);
|
||||
}, [cameraConfig, containerHeight]);
|
||||
|
||||
const [isPortraitImage, setIsPortraitImage] = useState(false);
|
||||
|
||||
|
||||
@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = {
|
||||
camera: {
|
||||
restartRequired: ["frame_height"],
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: [
|
||||
"threshold",
|
||||
"contour_area",
|
||||
"lightning_threshold",
|
||||
"improve_contrast",
|
||||
],
|
||||
fieldGroups: {
|
||||
sensitivity: ["threshold", "contour_area"],
|
||||
algorithm: ["improve_contrast"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"mask",
|
||||
"raw_mask",
|
||||
"mqtt_off_delay",
|
||||
"delta_alpha",
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
],
|
||||
advancedFields: ["lightning_threshold"],
|
||||
},
|
||||
};
|
||||
|
||||
export default motion;
|
||||
|
||||
@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = {
|
||||
camera: {
|
||||
restartRequired: [],
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["track", "filters"],
|
||||
fieldGroups: {
|
||||
tracking: ["track"],
|
||||
filtering: ["filters"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"alert",
|
||||
"detect",
|
||||
"mask",
|
||||
"raw_mask",
|
||||
"genai",
|
||||
"genai.enabled_in_config",
|
||||
"filters.*.mask",
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
};
|
||||
|
||||
export default objects;
|
||||
|
||||
@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
|
||||
base?: SectionConfig;
|
||||
global?: Partial<SectionConfig>;
|
||||
camera?: Partial<SectionConfig>;
|
||||
replay?: Partial<SectionConfig>;
|
||||
};
|
||||
|
||||
@ -95,9 +95,9 @@ export interface SectionConfig {
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
/** Whether this is at global or camera level */
|
||||
level: "global" | "camera";
|
||||
/** Camera name (required if level is "camera") */
|
||||
/** Whether this is at global, camera, or replay level */
|
||||
level: "global" | "camera" | "replay";
|
||||
/** Camera name (required if level is "camera" or "replay") */
|
||||
cameraName?: string;
|
||||
/** Whether to show override indicator badge */
|
||||
showOverrideIndicator?: boolean;
|
||||
@ -117,6 +117,10 @@ export interface BaseSectionProps {
|
||||
defaultCollapsed?: boolean;
|
||||
/** Whether to show the section title (default: false for global, true for camera) */
|
||||
showTitle?: boolean;
|
||||
/** If true, apply config in-memory only without writing to YAML */
|
||||
skipSave?: boolean;
|
||||
/** If true, buttons are not sticky at the bottom */
|
||||
noStickyButtons?: boolean;
|
||||
/** Callback when section status changes */
|
||||
onStatusChange?: (status: {
|
||||
hasChanges: boolean;
|
||||
@ -156,12 +160,16 @@ export function ConfigSection({
|
||||
collapsible = false,
|
||||
defaultCollapsed = true,
|
||||
showTitle,
|
||||
skipSave = false,
|
||||
noStickyButtons = false,
|
||||
onStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
}: ConfigSectionProps) {
|
||||
// For replay level, treat as camera-level config access
|
||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||
const { t, i18n } = useTranslation([
|
||||
level === "camera" ? "config/cameras" : "config/global",
|
||||
effectiveLevel === "camera" ? "config/cameras" : "config/global",
|
||||
"config/cameras",
|
||||
"views/settings",
|
||||
"common",
|
||||
@ -174,10 +182,10 @@ export function ConfigSection({
|
||||
// Create a key for this section's pending data
|
||||
const pendingDataKey = useMemo(
|
||||
() =>
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `${cameraName}::${sectionPath}`
|
||||
: sectionPath,
|
||||
[level, cameraName, sectionPath],
|
||||
[effectiveLevel, cameraName, sectionPath],
|
||||
);
|
||||
|
||||
// Use pending data from parent if available, otherwise use local state
|
||||
@ -222,20 +230,20 @@ export function ConfigSection({
|
||||
const lastPendingDataKeyRef = useRef<string | null>(null);
|
||||
|
||||
const updateTopic =
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: undefined
|
||||
: `config/${sectionPath}`;
|
||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||
const shouldShowTitle = showTitle ?? level === "camera";
|
||||
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
|
||||
|
||||
// Fetch config
|
||||
const { data: config, mutate: refreshConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
// Get section schema using cached hook
|
||||
const sectionSchema = useSectionSchema(sectionPath, level);
|
||||
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
|
||||
|
||||
// Apply special case handling for sections with problematic schema defaults
|
||||
const modifiedSchema = useMemo(
|
||||
@ -247,7 +255,7 @@ export function ConfigSection({
|
||||
// Get override status
|
||||
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
||||
config,
|
||||
cameraName: level === "camera" ? cameraName : undefined,
|
||||
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
|
||||
sectionPath,
|
||||
compareFields: sectionConfig.overrideFields,
|
||||
});
|
||||
@ -256,12 +264,12 @@ export function ConfigSection({
|
||||
const rawSectionValue = useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
|
||||
if (level === "camera" && cameraName) {
|
||||
if (effectiveLevel === "camera" && cameraName) {
|
||||
return get(config.cameras?.[cameraName], sectionPath);
|
||||
}
|
||||
|
||||
return get(config, sectionPath);
|
||||
}, [config, level, cameraName, sectionPath]);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel]);
|
||||
|
||||
const rawFormData = useMemo(() => {
|
||||
if (!config) return {};
|
||||
@ -328,9 +336,10 @@ export function ConfigSection({
|
||||
[rawFormData, sanitizeSectionData],
|
||||
);
|
||||
|
||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
||||
// Only clear if pendingData is managed locally (not by parent)
|
||||
// Clear pendingData whenever the section/camera key changes (e.g., switching
|
||||
// cameras) or when there is no pending data yet (initialization).
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit.
|
||||
// Only clear if pendingData is managed locally (not by parent).
|
||||
useEffect(() => {
|
||||
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
|
||||
|
||||
@ -339,15 +348,16 @@ export function ConfigSection({
|
||||
isInitializingRef.current = true;
|
||||
setPendingOverrides(undefined);
|
||||
setDirtyOverrides(undefined);
|
||||
|
||||
// Reset local pending data when switching sections/cameras
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
} else if (!pendingData) {
|
||||
isInitializingRef.current = true;
|
||||
setPendingOverrides(undefined);
|
||||
setDirtyOverrides(undefined);
|
||||
}
|
||||
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
}, [
|
||||
onPendingDataChange,
|
||||
pendingData,
|
||||
@ -484,7 +494,7 @@ export function ConfigSection({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const basePath =
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
const rawData = sanitizeSectionData(rawFormData);
|
||||
@ -495,7 +505,7 @@ export function ConfigSection({
|
||||
);
|
||||
const sanitizedOverrides = sanitizeOverridesForSection(
|
||||
sectionPath,
|
||||
level,
|
||||
effectiveLevel,
|
||||
overrides,
|
||||
);
|
||||
|
||||
@ -508,16 +518,26 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
|
||||
const needsRestart = skipSave
|
||||
? false
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||
await axios.put("config/set", {
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
config_data: configData,
|
||||
...(skipSave ? { skip_save: true } : {}),
|
||||
});
|
||||
|
||||
if (needsRestart) {
|
||||
if (skipSave) {
|
||||
toast.success(
|
||||
t("toast.applied", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Settings applied successfully",
|
||||
}),
|
||||
);
|
||||
} else if (needsRestart) {
|
||||
statusBar?.addMessage(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter", {
|
||||
@ -596,7 +616,7 @@ export function ConfigSection({
|
||||
}, [
|
||||
sectionPath,
|
||||
pendingData,
|
||||
level,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
t,
|
||||
refreshConfig,
|
||||
@ -608,15 +628,16 @@ export function ConfigSection({
|
||||
updateTopic,
|
||||
setPendingData,
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
const handleResetToGlobal = useCallback(async () => {
|
||||
if (level === "camera" && !cameraName) return;
|
||||
if (effectiveLevel === "camera" && !cameraName) return;
|
||||
|
||||
try {
|
||||
const basePath =
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
|
||||
@ -632,7 +653,7 @@ export function ConfigSection({
|
||||
t("toast.resetSuccess", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
level === "global"
|
||||
effectiveLevel === "global"
|
||||
? "Reset to defaults"
|
||||
: "Reset to global defaults",
|
||||
}),
|
||||
@ -651,7 +672,7 @@ export function ConfigSection({
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
level,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
requiresRestart,
|
||||
t,
|
||||
@ -661,8 +682,8 @@ export function ConfigSection({
|
||||
]);
|
||||
|
||||
const sectionValidation = useMemo(
|
||||
() => getSectionValidation({ sectionPath, level, t }),
|
||||
[sectionPath, level, t],
|
||||
() => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
|
||||
[sectionPath, effectiveLevel, t],
|
||||
);
|
||||
|
||||
const customValidate = useMemo(() => {
|
||||
@ -733,7 +754,7 @@ export function ConfigSection({
|
||||
// nested under the section name (e.g., `audio.label`). For global-level
|
||||
// sections, keys are nested under the section name in `config/global`.
|
||||
const configNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
effectiveLevel === "camera" ? "config/cameras" : "config/global";
|
||||
const title = t(`${sectionPath}.label`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: defaultTitle,
|
||||
@ -769,7 +790,7 @@ export function ConfigSection({
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level,
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
@ -784,7 +805,7 @@ export function ConfigSection({
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
@ -804,7 +825,12 @@ export function ConfigSection({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full border-t border-secondary bg-background pb-5 pt-0",
|
||||
!noStickyButtons && "sticky bottom-0 z-50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
||||
@ -822,15 +848,17 @@ export function ConfigSection({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
{((level === "camera" && isOverridden) || level === "global") &&
|
||||
!hasChanges && (
|
||||
{((effectiveLevel === "camera" && isOverridden) ||
|
||||
effectiveLevel === "global") &&
|
||||
!hasChanges &&
|
||||
!skipSave && (
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
{level === "global"
|
||||
{effectiveLevel === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
@ -862,11 +890,18 @@ export function ConfigSection({
|
||||
{isSaving ? (
|
||||
<>
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
{t("button.saving", {
|
||||
ns: "common",
|
||||
defaultValue: "Saving...",
|
||||
})}
|
||||
{skipSave
|
||||
? t("button.applying", {
|
||||
ns: "common",
|
||||
defaultValue: "Applying...",
|
||||
})
|
||||
: t("button.saving", {
|
||||
ns: "common",
|
||||
defaultValue: "Saving...",
|
||||
})}
|
||||
</>
|
||||
) : skipSave ? (
|
||||
t("button.apply", { ns: "common", defaultValue: "Apply" })
|
||||
) : (
|
||||
t("button.save", { ns: "common", defaultValue: "Save" })
|
||||
)}
|
||||
@ -898,7 +933,7 @@ export function ConfigSection({
|
||||
setIsResetDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{level === "global"
|
||||
{effectiveLevel === "global"
|
||||
? t("button.resetToDefault", { ns: "common" })
|
||||
: t("button.resetToGlobal", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
@ -923,7 +958,7 @@ export function ConfigSection({
|
||||
)}
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
level === "camera" &&
|
||||
effectiveLevel === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("button.overridden", {
|
||||
@ -967,7 +1002,7 @@ export function ConfigSection({
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
level === "camera" &&
|
||||
effectiveLevel === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
@ -93,7 +93,7 @@ export function AudioLabelSwitchesWidget(props: WidgetProps) {
|
||||
getDisplayLabel: getAudioLabelDisplayName,
|
||||
i18nKey: "audioLabels",
|
||||
listClassName:
|
||||
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
enableSearch: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -94,7 +94,7 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
|
||||
getDisplayLabel: getObjectLabelDisplayName,
|
||||
i18nKey: "objectLabels",
|
||||
listClassName:
|
||||
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -42,7 +42,7 @@ export function ZoneSwitchesWidget(props: WidgetProps) {
|
||||
getEntities: getZoneNames,
|
||||
getDisplayLabel: getZoneDisplayName,
|
||||
i18nKey: "zoneNames",
|
||||
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
|
||||
listClassName: "relative max-h-64 overflow-y-auto scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { useState, ReactNode } from "react";
|
||||
import { useState, ReactNode, useCallback } from "react";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@ -32,6 +32,7 @@ import useSWR from "swr";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import BlurredIconButton from "../button/BlurredIconButton";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type SearchResultActionsProps = {
|
||||
searchResult: SearchResult;
|
||||
@ -52,8 +53,10 @@ export default function SearchResultActions({
|
||||
isContextMenu = false,
|
||||
children,
|
||||
}: SearchResultActionsProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { t } = useTranslation(["views/explore", "views/replay", "common"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -84,6 +87,59 @@ export default function SearchResultActions({
|
||||
});
|
||||
};
|
||||
|
||||
const handleDebugReplay = useCallback(
|
||||
(event: SearchResult) => {
|
||||
setIsStarting(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (error.response?.status === 409) {
|
||||
toast.error(
|
||||
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
||||
{
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
action: (
|
||||
<a href="/replay" target="_blank" rel="noopener noreferrer">
|
||||
<Button>
|
||||
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStarting(false);
|
||||
});
|
||||
},
|
||||
[navigate, t],
|
||||
);
|
||||
|
||||
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
|
||||
|
||||
const menuItems = (
|
||||
@ -149,6 +205,20 @@ export default function SearchResultActions({
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_clip && (
|
||||
<MenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
disabled={isStarting}
|
||||
onSelect={() => {
|
||||
handleDebugReplay(searchResult);
|
||||
}}
|
||||
>
|
||||
{isStarting
|
||||
? t("dialog.starting", { ns: "views/replay" })
|
||||
: t("itemMenu.debugReplay.label")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<MenuItem
|
||||
aria-label={t("itemMenu.deleteTrackedObject.label")}
|
||||
|
||||
46
web/src/components/overlay/ActionsDropdown.tsx
Normal file
46
web/src/components/overlay/ActionsDropdown.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { Button } from "../ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFilm } from "react-icons/fa6";
|
||||
|
||||
type ActionsDropdownProps = {
|
||||
onDebugReplayClick: () => void;
|
||||
onExportClick: () => void;
|
||||
};
|
||||
|
||||
export default function ActionsDropdown({
|
||||
onDebugReplayClick,
|
||||
onExportClick,
|
||||
}: ActionsDropdownProps) {
|
||||
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("menu.actions", { ns: "common" })}
|
||||
size="sm"
|
||||
>
|
||||
<FaFilm className="size-5 text-secondary-foreground" />
|
||||
<div className="text-primary">
|
||||
{t("menu.actions", { ns: "common" })}
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onExportClick}>
|
||||
{t("menu.export", { ns: "common" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
240
web/src/components/overlay/CustomTimeSelector.tsx
Normal file
240
web/src/components/overlay/CustomTimeSelector.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { SelectSeparator } from "../ui/select";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
|
||||
import { FaArrowRight, FaCalendarAlt } from "react-icons/fa";
|
||||
import { isDesktop, isIOS } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CustomTimeSelectorProps = {
|
||||
latestTime: number;
|
||||
range?: TimeRange;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
startLabel: string;
|
||||
endLabel: string;
|
||||
};
|
||||
|
||||
export function CustomTimeSelector({
|
||||
latestTime,
|
||||
range,
|
||||
setRange,
|
||||
startLabel,
|
||||
endLabel,
|
||||
}: CustomTimeSelectorProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// times
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
config?.ui.timezone
|
||||
? Math.round(getUTCOffset(new Date(), config.ui.timezone))
|
||||
: undefined,
|
||||
[config?.ui.timezone],
|
||||
);
|
||||
const localTimeOffset = useMemo(
|
||||
() =>
|
||||
Math.round(
|
||||
getUTCOffset(
|
||||
new Date(),
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const startTime = useMemo(() => {
|
||||
let time = range?.after || latestTime - 3600;
|
||||
|
||||
if (timezoneOffset) {
|
||||
time = time + (timezoneOffset - localTimeOffset) * 60;
|
||||
}
|
||||
|
||||
return time;
|
||||
}, [range, latestTime, timezoneOffset, localTimeOffset]);
|
||||
|
||||
const endTime = useMemo(() => {
|
||||
let time = range?.before || latestTime;
|
||||
|
||||
if (timezoneOffset) {
|
||||
time = time + (timezoneOffset - localTimeOffset) * 60;
|
||||
}
|
||||
|
||||
return time;
|
||||
}, [range, latestTime, timezoneOffset, localTimeOffset]);
|
||||
|
||||
const formattedStart = useFormattedTimestamp(
|
||||
startTime,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp.24hour")
|
||||
: t("time.formattedTimestamp.12hour"),
|
||||
);
|
||||
|
||||
const formattedEnd = useFormattedTimestamp(
|
||||
endTime,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp.24hour")
|
||||
: t("time.formattedTimestamp.12hour"),
|
||||
);
|
||||
|
||||
const startClock = useMemo(() => {
|
||||
const date = new Date(startTime * 1000);
|
||||
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
|
||||
}, [startTime]);
|
||||
|
||||
const endClock = useMemo(() => {
|
||||
const date = new Date(endTime * 1000);
|
||||
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
|
||||
}, [endTime]);
|
||||
|
||||
// calendars
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [endOpen, setEndOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-2"}`}
|
||||
>
|
||||
<FaCalendarAlt />
|
||||
<div className="flex flex-wrap items-center">
|
||||
<Popover
|
||||
open={startOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setStartOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label={startLabel}
|
||||
variant={startOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStartOpen(true);
|
||||
setEndOpen(false);
|
||||
}}
|
||||
>
|
||||
{formattedStart}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center" disablePortal>
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(startTime * 1000)}
|
||||
onSelect={(day) => {
|
||||
if (!day) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: endTime,
|
||||
after: day.getTime() / 1000 + 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
step={isIOS ? "60" : "1"}
|
||||
onChange={(e) => {
|
||||
const clock = e.target.value;
|
||||
const [hour, minute, second] = isIOS
|
||||
? [...clock.split(":"), "00"]
|
||||
: clock.split(":");
|
||||
|
||||
const start = new Date(startTime * 1000);
|
||||
start.setHours(
|
||||
parseInt(hour),
|
||||
parseInt(minute),
|
||||
parseInt(second ?? 0),
|
||||
0,
|
||||
);
|
||||
setRange({
|
||||
before: endTime,
|
||||
after: start.getTime() / 1000,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FaArrowRight className="size-4 text-primary" />
|
||||
<Popover
|
||||
open={endOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEndOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label={endLabel}
|
||||
variant={endOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEndOpen(true);
|
||||
setStartOpen(false);
|
||||
}}
|
||||
>
|
||||
{formattedEnd}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center" disablePortal>
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(endTime * 1000)}
|
||||
onSelect={(day) => {
|
||||
if (!day) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRange({
|
||||
after: startTime,
|
||||
before: day.getTime() / 1000,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
step={isIOS ? "60" : "1"}
|
||||
onChange={(e) => {
|
||||
const clock = e.target.value;
|
||||
const [hour, minute, second] = isIOS
|
||||
? [...clock.split(":"), "00"]
|
||||
: clock.split(":");
|
||||
|
||||
const end = new Date(endTime * 1000);
|
||||
end.setHours(
|
||||
parseInt(hour),
|
||||
parseInt(minute),
|
||||
parseInt(second ?? 0),
|
||||
0,
|
||||
);
|
||||
setRange({
|
||||
before: end.getTime() / 1000,
|
||||
after: startTime,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
web/src/components/overlay/DebugReplayDialog.tsx
Normal file
367
web/src/components/overlay/DebugReplayDialog.tsx
Normal file
@ -0,0 +1,367 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
import { Button } from "../ui/button";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SelectSeparator } from "../ui/select";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { LuBug, LuPlay, LuX } from "react-icons/lu";
|
||||
import { ExportMode } from "@/types/filter";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CustomTimeSelector } from "./CustomTimeSelector";
|
||||
|
||||
const REPLAY_TIME_OPTIONS = ["1", "5", "timeline", "custom"] as const;
|
||||
type ReplayTimeOption = (typeof REPLAY_TIME_OPTIONS)[number];
|
||||
|
||||
type DebugReplayContentProps = {
|
||||
currentTime: number;
|
||||
latestTime: number;
|
||||
range?: TimeRange;
|
||||
selectedOption: ReplayTimeOption;
|
||||
isStarting: boolean;
|
||||
onSelectedOptionChange: (option: ReplayTimeOption) => void;
|
||||
onStart: () => void;
|
||||
onCancel: () => void;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
setMode: (mode: ExportMode) => void;
|
||||
};
|
||||
|
||||
export function DebugReplayContent({
|
||||
currentTime,
|
||||
latestTime,
|
||||
range,
|
||||
selectedOption,
|
||||
isStarting,
|
||||
onSelectedOptionChange,
|
||||
onStart,
|
||||
onCancel,
|
||||
setRange,
|
||||
setMode,
|
||||
}: DebugReplayContentProps) {
|
||||
const { t } = useTranslation(["views/replay"]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{isDesktop && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dialog.title")}</DialogTitle>
|
||||
<DialogDescription>{t("dialog.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SelectSeparator className="my-4 bg-secondary" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Time range */}
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<RadioGroup
|
||||
className="mt-2 flex flex-col gap-4"
|
||||
value={selectedOption}
|
||||
onValueChange={(value) =>
|
||||
onSelectedOptionChange(value as ReplayTimeOption)
|
||||
}
|
||||
>
|
||||
{REPLAY_TIME_OPTIONS.map((opt) => (
|
||||
<div key={opt} className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
opt === selectedOption
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id={`replay-${opt}`}
|
||||
value={opt}
|
||||
/>
|
||||
<Label className="cursor-pointer" htmlFor={`replay-${opt}`}>
|
||||
{opt === "custom"
|
||||
? t("dialog.preset.custom")
|
||||
: opt === "timeline"
|
||||
? t("dialog.preset.timeline")
|
||||
: t(`dialog.preset.${opt}m`)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Custom time inputs */}
|
||||
{selectedOption === "custom" && (
|
||||
<CustomTimeSelector
|
||||
latestTime={latestTime}
|
||||
range={range}
|
||||
setRange={setRange}
|
||||
startLabel={t("dialog.startLabel")}
|
||||
endLabel={t("dialog.endLabel")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||
|
||||
<DialogFooter
|
||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
|
||||
>
|
||||
<div
|
||||
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</div>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isStarting}
|
||||
onClick={() => {
|
||||
if (selectedOption === "timeline") {
|
||||
setRange({
|
||||
after: currentTime - 30,
|
||||
before: currentTime + 30,
|
||||
});
|
||||
setMode("timeline");
|
||||
} else {
|
||||
onStart();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isStarting ? <ActivityIndicator className="mr-2" /> : null}
|
||||
{isStarting
|
||||
? t("dialog.starting")
|
||||
: selectedOption === "timeline"
|
||||
? t("dialog.selectFromTimeline")
|
||||
: t("dialog.startButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DebugReplayDialogProps = {
|
||||
camera: string;
|
||||
currentTime: number;
|
||||
latestTime: number;
|
||||
range?: TimeRange;
|
||||
mode: ExportMode;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
setMode: (mode: ExportMode) => void;
|
||||
};
|
||||
|
||||
export default function DebugReplayDialog({
|
||||
camera,
|
||||
currentTime,
|
||||
latestTime,
|
||||
range,
|
||||
mode,
|
||||
setRange,
|
||||
setMode,
|
||||
}: DebugReplayDialogProps) {
|
||||
const { t } = useTranslation(["views/replay"]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<ReplayTimeOption>("1");
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const handleTimeOptionChange = useCallback(
|
||||
(option: ReplayTimeOption) => {
|
||||
setSelectedOption(option);
|
||||
|
||||
if (option === "custom" || option === "timeline") {
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = parseInt(option, 10);
|
||||
const end = latestTime;
|
||||
setRange({ after: end - minutes * 60, before: end });
|
||||
},
|
||||
[latestTime, setRange],
|
||||
);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
if (!range || range.before <= range.after) {
|
||||
toast.error(
|
||||
t("dialog.toast.error", { error: "End time must be after start time" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStarting(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: camera,
|
||||
start_time: range.after,
|
||||
end_time: range.before,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (error.response?.status === 409) {
|
||||
toast.error(t("dialog.toast.alreadyActive"), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
action: (
|
||||
<a href="/replay" target="_blank" rel="noopener noreferrer">
|
||||
<Button>{t("dialog.toast.goToReplay")}</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStarting(false);
|
||||
});
|
||||
}, [camera, range, navigate, setMode, setRange, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
}, [setMode, setRange]);
|
||||
|
||||
const Overlay = isDesktop ? Dialog : Drawer;
|
||||
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
||||
const Content = isDesktop ? DialogContent : DrawerContent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SaveDebugReplayOverlay
|
||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||
show={mode == "timeline"}
|
||||
isStarting={isStarting}
|
||||
onSave={handleStart}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<Overlay
|
||||
open={mode == "select"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setMode("none");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isDesktop && (
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("title")}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const end = latestTime;
|
||||
setRange({ after: end - 60, before: end });
|
||||
setSelectedOption("1");
|
||||
setMode("select");
|
||||
}}
|
||||
>
|
||||
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
|
||||
{isDesktop && <div className="text-primary">{t("title")}</div>}
|
||||
</Button>
|
||||
</Trigger>
|
||||
)}
|
||||
<Content
|
||||
className={
|
||||
isDesktop
|
||||
? "max-h-[90dvh] w-auto max-w-2xl overflow-visible sm:rounded-lg md:rounded-2xl"
|
||||
: "max-h-[75dvh] overflow-y-auto rounded-lg px-4 pb-4 md:rounded-2xl"
|
||||
}
|
||||
>
|
||||
<DebugReplayContent
|
||||
currentTime={currentTime}
|
||||
latestTime={latestTime}
|
||||
range={range}
|
||||
selectedOption={selectedOption}
|
||||
isStarting={isStarting}
|
||||
onSelectedOptionChange={handleTimeOptionChange}
|
||||
onStart={handleStart}
|
||||
onCancel={handleCancel}
|
||||
setRange={setRange}
|
||||
setMode={setMode}
|
||||
/>
|
||||
</Content>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveDebugReplayOverlayProps = {
|
||||
className: string;
|
||||
show: boolean;
|
||||
isStarting: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function SaveDebugReplayOverlay({
|
||||
className,
|
||||
show,
|
||||
isStarting,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: SaveDebugReplayOverlayProps) {
|
||||
const { t } = useTranslation(["views/replay"]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-center justify-center gap-2 rounded-lg px-2",
|
||||
show ? "duration-500 animate-in slide-in-from-top" : "invisible",
|
||||
"mx-auto mt-5 text-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="flex items-center gap-1 text-primary"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
size="sm"
|
||||
disabled={isStarting}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<LuX />
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("dialog.startButton")}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isStarting}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isStarting ? <ActivityIndicator className="size-4" /> : <LuPlay />}
|
||||
{isStarting ? t("dialog.starting") : t("dialog.startButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -12,16 +12,12 @@ import { Label } from "../ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
import { Button } from "../ui/button";
|
||||
import { ExportMode } from "@/types/filter";
|
||||
import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa";
|
||||
import { FaArrowDown } from "react-icons/fa";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "../ui/input";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -30,15 +26,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { isDesktop, isIOS, isMobile } from "react-device-detect";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExportCase } from "@/types/export";
|
||||
import { CustomTimeSelector } from "./CustomTimeSelector";
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
"1",
|
||||
@ -167,31 +163,33 @@ export default function ExportDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("menu.export", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const now = new Date(latestTime * 1000);
|
||||
let start = 0;
|
||||
now.setHours(now.getHours() - 1);
|
||||
start = now.getTime() / 1000;
|
||||
setRange({
|
||||
before: latestTime,
|
||||
after: start,
|
||||
});
|
||||
setMode("select");
|
||||
}}
|
||||
>
|
||||
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("menu.export", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</Trigger>
|
||||
{!isDesktop && (
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("menu.export", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const now = new Date(latestTime * 1000);
|
||||
let start = 0;
|
||||
now.setHours(now.getHours() - 1);
|
||||
start = now.getTime() / 1000;
|
||||
setRange({
|
||||
before: latestTime,
|
||||
after: start,
|
||||
});
|
||||
setMode("select");
|
||||
}}
|
||||
>
|
||||
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("menu.export", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</Trigger>
|
||||
)}
|
||||
<Content
|
||||
className={
|
||||
isDesktop
|
||||
@ -332,6 +330,8 @@ export function ExportContent({
|
||||
latestTime={latestTime}
|
||||
range={range}
|
||||
setRange={setRange}
|
||||
startLabel={t("export.time.start.title")}
|
||||
endLabel={t("export.time.end.title")}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
@ -414,234 +414,6 @@ export function ExportContent({
|
||||
);
|
||||
}
|
||||
|
||||
type CustomTimeSelectorProps = {
|
||||
latestTime: number;
|
||||
range?: TimeRange;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
};
|
||||
function CustomTimeSelector({
|
||||
latestTime,
|
||||
range,
|
||||
setRange,
|
||||
}: CustomTimeSelectorProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// times
|
||||
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
config?.ui.timezone
|
||||
? Math.round(getUTCOffset(new Date(), config.ui.timezone))
|
||||
: undefined,
|
||||
[config?.ui.timezone],
|
||||
);
|
||||
const localTimeOffset = useMemo(
|
||||
() =>
|
||||
Math.round(
|
||||
getUTCOffset(
|
||||
new Date(),
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const startTime = useMemo(() => {
|
||||
let time = range?.after || latestTime - 3600;
|
||||
|
||||
if (timezoneOffset) {
|
||||
time = time + (timezoneOffset - localTimeOffset) * 60;
|
||||
}
|
||||
|
||||
return time;
|
||||
}, [range, latestTime, timezoneOffset, localTimeOffset]);
|
||||
const endTime = useMemo(() => {
|
||||
let time = range?.before || latestTime;
|
||||
|
||||
if (timezoneOffset) {
|
||||
time = time + (timezoneOffset - localTimeOffset) * 60;
|
||||
}
|
||||
|
||||
return time;
|
||||
}, [range, latestTime, timezoneOffset, localTimeOffset]);
|
||||
const formattedStart = useFormattedTimestamp(
|
||||
startTime,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
||||
);
|
||||
const formattedEnd = useFormattedTimestamp(
|
||||
endTime,
|
||||
config?.ui.time_format == "24hour"
|
||||
? t("time.formattedTimestamp.24hour", { ns: "common" })
|
||||
: t("time.formattedTimestamp.12hour", { ns: "common" }),
|
||||
);
|
||||
|
||||
const startClock = useMemo(() => {
|
||||
const date = new Date(startTime * 1000);
|
||||
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
|
||||
}, [startTime]);
|
||||
const endClock = useMemo(() => {
|
||||
const date = new Date(endTime * 1000);
|
||||
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
|
||||
}, [endTime]);
|
||||
|
||||
// calendars
|
||||
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [endOpen, setEndOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-2"}`}
|
||||
>
|
||||
<FaCalendarAlt />
|
||||
<div className="flex flex-wrap items-center">
|
||||
<Popover
|
||||
modal={false}
|
||||
open={startOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setStartOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label={t("export.time.start.title")}
|
||||
variant={startOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStartOpen(true);
|
||||
setEndOpen(false);
|
||||
}}
|
||||
>
|
||||
{formattedStart}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
disablePortal={isDesktop}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(startTime * 1000)}
|
||||
onSelect={(day) => {
|
||||
if (!day) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: endTime,
|
||||
after: day.getTime() / 1000 + 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={startClock}
|
||||
step={isIOS ? "60" : "1"}
|
||||
onChange={(e) => {
|
||||
const clock = e.target.value;
|
||||
const [hour, minute, second] = isIOS
|
||||
? [...clock.split(":"), "00"]
|
||||
: clock.split(":");
|
||||
|
||||
const start = new Date(startTime * 1000);
|
||||
start.setHours(
|
||||
parseInt(hour),
|
||||
parseInt(minute),
|
||||
parseInt(second ?? 0),
|
||||
0,
|
||||
);
|
||||
setRange({
|
||||
before: endTime,
|
||||
after: start.getTime() / 1000,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FaArrowRight className="size-4 text-primary" />
|
||||
<Popover
|
||||
modal={false}
|
||||
open={endOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEndOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||
aria-label={t("export.time.end.title")}
|
||||
variant={endOpen ? "select" : "default"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEndOpen(true);
|
||||
setStartOpen(false);
|
||||
}}
|
||||
>
|
||||
{formattedEnd}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
disablePortal={isDesktop}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(endTime * 1000)}
|
||||
onSelect={(day) => {
|
||||
if (!day) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRange({
|
||||
after: startTime,
|
||||
before: day.getTime() / 1000,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<SelectSeparator className="bg-secondary" />
|
||||
<input
|
||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={endClock}
|
||||
step={isIOS ? "60" : "1"}
|
||||
onChange={(e) => {
|
||||
const clock = e.target.value;
|
||||
const [hour, minute, second] = isIOS
|
||||
? [...clock.split(":"), "00"]
|
||||
: clock.split(":");
|
||||
|
||||
const end = new Date(endTime * 1000);
|
||||
end.setHours(
|
||||
parseInt(hour),
|
||||
parseInt(minute),
|
||||
parseInt(second ?? 0),
|
||||
0,
|
||||
);
|
||||
setRange({
|
||||
before: end.getTime() / 1000,
|
||||
after: startTime,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ExportPreviewDialogProps = {
|
||||
camera: string;
|
||||
range?: TimeRange;
|
||||
|
||||
@ -2,8 +2,13 @@ import { useCallback, useState } from "react";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||
import { LuBug } from "react-icons/lu";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
||||
import {
|
||||
DebugReplayContent,
|
||||
SaveDebugReplayOverlay,
|
||||
} from "./DebugReplayDialog";
|
||||
import { ExportMode, GeneralFilter } from "@/types/filter";
|
||||
import ReviewActivityCalendar from "./ReviewActivityCalendar";
|
||||
import { SelectSeparator } from "../ui/select";
|
||||
@ -16,19 +21,32 @@ import {
|
||||
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type DrawerMode = "none" | "select" | "export" | "calendar" | "filter";
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
| "select"
|
||||
| "export"
|
||||
| "calendar"
|
||||
| "filter"
|
||||
| "debug-replay";
|
||||
|
||||
const DRAWER_FEATURES = ["export", "calendar", "filter"] as const;
|
||||
const DRAWER_FEATURES = [
|
||||
"export",
|
||||
"calendar",
|
||||
"filter",
|
||||
"debug-replay",
|
||||
] as const;
|
||||
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
|
||||
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
|
||||
"export",
|
||||
"calendar",
|
||||
"filter",
|
||||
"debug-replay",
|
||||
];
|
||||
|
||||
type MobileReviewSettingsDrawerProps = {
|
||||
@ -45,6 +63,10 @@ type MobileReviewSettingsDrawerProps = {
|
||||
recordingsSummary?: RecordingsSummary;
|
||||
allLabels: string[];
|
||||
allZones: string[];
|
||||
debugReplayMode?: ExportMode;
|
||||
debugReplayRange?: TimeRange;
|
||||
setDebugReplayMode?: (mode: ExportMode) => void;
|
||||
setDebugReplayRange?: (range: TimeRange | undefined) => void;
|
||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
setMode: (mode: ExportMode) => void;
|
||||
@ -64,13 +86,26 @@ export default function MobileReviewSettingsDrawer({
|
||||
recordingsSummary,
|
||||
allLabels,
|
||||
allZones,
|
||||
debugReplayMode = "none",
|
||||
debugReplayRange,
|
||||
setDebugReplayMode = () => {},
|
||||
setDebugReplayRange = () => {},
|
||||
onUpdateFilter,
|
||||
setRange,
|
||||
setMode,
|
||||
setShowExportPreview,
|
||||
}: MobileReviewSettingsDrawerProps) {
|
||||
const { t } = useTranslation(["views/recording", "components/dialog"]);
|
||||
const { t } = useTranslation([
|
||||
"views/recording",
|
||||
"components/dialog",
|
||||
"views/replay",
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||
"1" | "5" | "custom" | "timeline"
|
||||
>("1");
|
||||
const [isDebugReplayStarting, setIsDebugReplayStarting] = useState(false);
|
||||
|
||||
// exports
|
||||
|
||||
@ -140,6 +175,76 @@ export default function MobileReviewSettingsDrawer({
|
||||
});
|
||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
||||
|
||||
const onStartDebugReplay = useCallback(async () => {
|
||||
if (
|
||||
!debugReplayRange ||
|
||||
debugReplayRange.before <= debugReplayRange.after
|
||||
) {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
error: "End time must be after start time",
|
||||
ns: "views/replay",
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDebugReplayStarting(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post("debug_replay/start", {
|
||||
camera: camera,
|
||||
start_time: debugReplayRange.after,
|
||||
end_time: debugReplayRange.before,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setDebugReplayMode("none");
|
||||
setDebugReplayRange(undefined);
|
||||
setDrawerMode("none");
|
||||
navigate("/replay");
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{
|
||||
message?: string;
|
||||
detail?: string;
|
||||
}>;
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (axiosError.response?.status === 409) {
|
||||
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
error: errorMessage,
|
||||
ns: "views/replay",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsDebugReplayStarting(false);
|
||||
}
|
||||
}, [
|
||||
camera,
|
||||
debugReplayRange,
|
||||
navigate,
|
||||
setDebugReplayMode,
|
||||
setDebugReplayRange,
|
||||
t,
|
||||
]);
|
||||
|
||||
// filters
|
||||
|
||||
const [currentFilter, setCurrentFilter] = useState<GeneralFilter>({
|
||||
@ -196,6 +301,26 @@ export default function MobileReviewSettingsDrawer({
|
||||
{t("filter")}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("debug-replay") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
onClick={() => {
|
||||
const now = new Date(latestTime * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
setDebugReplayRange({
|
||||
after: now.getTime() / 1000,
|
||||
before: latestTime,
|
||||
});
|
||||
setSelectedReplayOption("1");
|
||||
setDrawerMode("debug-replay");
|
||||
setDebugReplayMode("select");
|
||||
}}
|
||||
>
|
||||
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (drawerMode == "export") {
|
||||
@ -311,6 +436,47 @@ export default function MobileReviewSettingsDrawer({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (drawerMode == "debug-replay") {
|
||||
const handleTimeOptionChange = (
|
||||
option: "1" | "5" | "custom" | "timeline",
|
||||
) => {
|
||||
setSelectedReplayOption(option);
|
||||
|
||||
if (option === "custom" || option === "timeline") {
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = parseInt(option);
|
||||
const end = latestTime;
|
||||
const now = new Date(end * 1000);
|
||||
now.setHours(now.getHours() - hours);
|
||||
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
|
||||
};
|
||||
|
||||
content = (
|
||||
<DebugReplayContent
|
||||
currentTime={currentTime}
|
||||
latestTime={latestTime}
|
||||
range={debugReplayRange}
|
||||
selectedOption={selectedReplayOption}
|
||||
isStarting={isDebugReplayStarting}
|
||||
onSelectedOptionChange={handleTimeOptionChange}
|
||||
onStart={onStartDebugReplay}
|
||||
onCancel={() => {
|
||||
setDebugReplayMode("none");
|
||||
setDebugReplayRange(undefined);
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
setRange={setDebugReplayRange}
|
||||
setMode={(mode) => {
|
||||
setDebugReplayMode(mode);
|
||||
|
||||
if (mode == "timeline") {
|
||||
setDrawerMode("none");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -322,6 +488,16 @@ export default function MobileReviewSettingsDrawer({
|
||||
onCancel={() => setMode("none")}
|
||||
onPreview={() => setShowExportPreview(true)}
|
||||
/>
|
||||
<SaveDebugReplayOverlay
|
||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||
show={debugReplayRange != undefined && debugReplayMode == "timeline"}
|
||||
isStarting={isDebugReplayStarting}
|
||||
onSave={onStartDebugReplay}
|
||||
onCancel={() => {
|
||||
setDebugReplayMode("none");
|
||||
setDebugReplayRange(undefined);
|
||||
}}
|
||||
/>
|
||||
<ExportPreviewDialog
|
||||
camera={camera}
|
||||
range={range}
|
||||
@ -354,7 +530,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
/>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="mx-1 flex max-h-[80dvh] flex-col items-center gap-2 overflow-hidden rounded-t-2xl px-4 pb-4">
|
||||
<DrawerContent
|
||||
className={`mx-1 flex max-h-[80dvh] flex-col items-center gap-2 rounded-t-2xl px-4 pb-4 ${drawerMode == "export" || drawerMode == "debug-replay" ? "overflow-visible" : "overflow-hidden"}`}
|
||||
>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { Event } from "@/types/event";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
@ -243,6 +244,8 @@ export function TrackingDetails({
|
||||
}, [manualOverride, currentTime, annotationOffset]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
||||
useFullscreen(containerRef);
|
||||
const timelineContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [_selectedZone, setSelectedZone] = useState("");
|
||||
@ -559,14 +562,15 @@ export function TrackingDetails({
|
||||
visible={true}
|
||||
currentSource={videoSource}
|
||||
hotKeys={false}
|
||||
supportsFullscreen={false}
|
||||
fullscreen={false}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
fullscreen={fullscreen}
|
||||
frigateControls={true}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeekToTime={handleSeekToTime}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
onPlaying={() => setIsVideoLoading(false)}
|
||||
setFullResolution={setFullResolution}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
isDetailMode={true}
|
||||
camera={event.camera}
|
||||
currentTimeOverride={currentTime}
|
||||
|
||||
@ -12,8 +12,11 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
type EventMenuProps = {
|
||||
event: Event;
|
||||
@ -34,9 +37,10 @@ export default function EventMenu({
|
||||
}: EventMenuProps) {
|
||||
const apiHost = useApiHost();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("views/explore");
|
||||
const { t } = useTranslation(["views/explore", "views/replay"]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isAdmin = useIsAdmin();
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const handleObjectSelect = () => {
|
||||
if (isSelected) {
|
||||
@ -46,6 +50,59 @@ export default function EventMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDebugReplay = useCallback(
|
||||
(event: Event) => {
|
||||
setIsStarting(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: event.camera,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (error.response?.status === 409) {
|
||||
toast.error(
|
||||
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
|
||||
{
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
action: (
|
||||
<a href="/replay" target="_blank" rel="noopener noreferrer">
|
||||
<Button>
|
||||
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStarting(false);
|
||||
});
|
||||
},
|
||||
[navigate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
@ -117,6 +174,19 @@ export default function EventMenu({
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{event.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={isStarting}
|
||||
onSelect={() => {
|
||||
handleDebugReplay(event);
|
||||
}}
|
||||
>
|
||||
{isStarting
|
||||
? t("dialog.starting", { ns: "views/replay" })
|
||||
: t("itemMenu.debugReplay.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
|
||||
608
web/src/components/ws/WsMessageFeed.tsx
Normal file
608
web/src/components/ws/WsMessageFeed.tsx
Normal file
@ -0,0 +1,608 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { WsFeedMessage } from "@/api/ws";
|
||||
import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer";
|
||||
import WsMessageRow from "./WsMessageRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FaEraser, FaFilter, FaPause, FaPlay, FaVideo } from "react-icons/fa";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
type TopicCategory =
|
||||
| "events"
|
||||
| "camera_activity"
|
||||
| "system"
|
||||
| "reviews"
|
||||
| "classification"
|
||||
| "face_recognition"
|
||||
| "lpr";
|
||||
|
||||
const ALL_TOPIC_CATEGORIES: TopicCategory[] = [
|
||||
"events",
|
||||
"reviews",
|
||||
"classification",
|
||||
"face_recognition",
|
||||
"lpr",
|
||||
"camera_activity",
|
||||
"system",
|
||||
];
|
||||
|
||||
const PRESET_TOPICS: Record<TopicCategory, Set<string>> = {
|
||||
events: new Set(["events", "triggers"]),
|
||||
reviews: new Set(["reviews"]),
|
||||
classification: new Set(["tracked_object_update"]),
|
||||
face_recognition: new Set(["tracked_object_update"]),
|
||||
lpr: new Set(["tracked_object_update"]),
|
||||
camera_activity: new Set(["camera_activity", "audio_detections"]),
|
||||
system: new Set([
|
||||
"stats",
|
||||
"model_state",
|
||||
"job_state",
|
||||
"embeddings_reindex_progress",
|
||||
"audio_transcription_state",
|
||||
"birdseye_layout",
|
||||
]),
|
||||
};
|
||||
|
||||
// Maps tracked_object_update payload type to TopicCategory
|
||||
const TRACKED_UPDATE_TYPE_MAP: Record<string, TopicCategory> = {
|
||||
classification: "classification",
|
||||
face: "face_recognition",
|
||||
lpr: "lpr",
|
||||
};
|
||||
|
||||
// camera_activity preset also matches topics with camera prefix patterns
|
||||
const CAMERA_ACTIVITY_TOPIC_PATTERNS = [
|
||||
"/motion",
|
||||
"/audio",
|
||||
"/detect",
|
||||
"/recordings",
|
||||
"/enabled",
|
||||
"/snapshots",
|
||||
"/ptz",
|
||||
];
|
||||
|
||||
function matchesCategories(
|
||||
msg: WsFeedMessage,
|
||||
categories: TopicCategory[] | undefined,
|
||||
): boolean {
|
||||
// undefined means all topics
|
||||
if (!categories) return true;
|
||||
|
||||
const { topic, payload } = msg;
|
||||
|
||||
// Handle tracked_object_update with payload-based sub-categories
|
||||
if (topic === "tracked_object_update") {
|
||||
// payload might be a JSON string or a parsed object
|
||||
let data: unknown = payload;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
// not valid JSON, fall through
|
||||
}
|
||||
}
|
||||
|
||||
const updateType =
|
||||
data && typeof data === "object" && "type" in data
|
||||
? (data as { type: string }).type
|
||||
: undefined;
|
||||
|
||||
if (updateType && updateType in TRACKED_UPDATE_TYPE_MAP) {
|
||||
const mappedCategory = TRACKED_UPDATE_TYPE_MAP[updateType];
|
||||
return categories.includes(mappedCategory);
|
||||
}
|
||||
|
||||
// tracked_object_update with other types (e.g. "description") falls under "events"
|
||||
return categories.includes("events");
|
||||
}
|
||||
|
||||
for (const cat of categories) {
|
||||
const topicSet = PRESET_TOPICS[cat];
|
||||
if (topicSet.has(topic)) return true;
|
||||
|
||||
if (cat === "camera_activity") {
|
||||
if (
|
||||
CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) =>
|
||||
topic.includes(pattern),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type WsMessageFeedProps = {
|
||||
maxSize?: number;
|
||||
defaultCamera?: string;
|
||||
lockedCamera?: string;
|
||||
showCameraBadge?: boolean;
|
||||
};
|
||||
|
||||
export default function WsMessageFeed({
|
||||
maxSize = 500,
|
||||
defaultCamera,
|
||||
lockedCamera,
|
||||
showCameraBadge = true,
|
||||
}: WsMessageFeedProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
// undefined = all topics
|
||||
const [selectedTopics, setSelectedTopics] = useState<
|
||||
TopicCategory[] | undefined
|
||||
>(undefined);
|
||||
// undefined = all cameras
|
||||
const [selectedCameras, setSelectedCameras] = useState<string[] | undefined>(
|
||||
() => {
|
||||
if (lockedCamera) return [lockedCamera];
|
||||
if (defaultCamera) return [defaultCamera];
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const { messages, clear } = useWsMessageBuffer(maxSize, paused, {
|
||||
cameraFilter: selectedCameras,
|
||||
});
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const availableCameras = useMemo(() => {
|
||||
if (!config?.cameras) return [];
|
||||
return Object.keys(config.cameras)
|
||||
.filter((name) => {
|
||||
const cam = config.cameras[name];
|
||||
return !isReplayCamera(name) && cam.enabled_in_config;
|
||||
})
|
||||
.sort();
|
||||
}, [config]);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
return messages.filter((msg: WsFeedMessage) => {
|
||||
if (!matchesCategories(msg, selectedTopics)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [messages, selectedTopics]);
|
||||
|
||||
// Auto-scroll logic
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el || !autoScrollRef.current) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [filteredMessages.length]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-row flex-wrap items-center justify-between gap-2 border-b border-secondary p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<TopicFilterButton
|
||||
selectedTopics={selectedTopics}
|
||||
updateTopicFilter={setSelectedTopics}
|
||||
/>
|
||||
|
||||
{!lockedCamera && (
|
||||
<WsCamerasFilterButton
|
||||
allCameras={availableCameras}
|
||||
selectedCameras={selectedCameras}
|
||||
updateCameraFilter={setSelectedCameras}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs text-primary-variant">
|
||||
{t("logs.websocket.count", {
|
||||
count: filteredMessages.length,
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => setPaused(!paused)}
|
||||
aria-label={
|
||||
paused ? t("logs.websocket.resume") : t("logs.websocket.pause")
|
||||
}
|
||||
>
|
||||
{paused ? (
|
||||
<FaPlay className="size-2.5" />
|
||||
) : (
|
||||
<FaPause className="size-2.5" />
|
||||
)}
|
||||
{paused ? t("logs.websocket.resume") : t("logs.websocket.pause")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={clear}
|
||||
aria-label={t("logs.websocket.clear")}
|
||||
>
|
||||
<FaEraser className="size-2.5" />
|
||||
{t("logs.websocket.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed area */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="scrollbar-container flex-1 overflow-y-auto"
|
||||
>
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="flex size-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||
{t("logs.websocket.empty")}
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((msg: WsFeedMessage) => (
|
||||
<WsMessageRow
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
showCameraBadge={showCameraBadge}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Topic Filter Button
|
||||
|
||||
type TopicFilterButtonProps = {
|
||||
selectedTopics: TopicCategory[] | undefined;
|
||||
updateTopicFilter: (topics: TopicCategory[] | undefined) => void;
|
||||
};
|
||||
|
||||
function TopicFilterButton({
|
||||
selectedTopics,
|
||||
updateTopicFilter,
|
||||
}: TopicFilterButtonProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentTopics, setCurrentTopics] = useState<
|
||||
TopicCategory[] | undefined
|
||||
>(selectedTopics);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTopics(selectedTopics);
|
||||
}, [selectedTopics]);
|
||||
|
||||
const isFiltered = selectedTopics !== undefined;
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant={isFiltered ? "select" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
aria-label={t("logs.websocket.filter.all")}
|
||||
>
|
||||
<FaFilter
|
||||
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<span className={isFiltered ? "text-selected-foreground" : ""}>
|
||||
{t("logs.websocket.filter.topics")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<TopicFilterContent
|
||||
currentTopics={currentTopics}
|
||||
setCurrentTopics={setCurrentTopics}
|
||||
onApply={() => {
|
||||
updateTopicFilter(currentTopics);
|
||||
setOpen(false);
|
||||
}}
|
||||
onReset={() => {
|
||||
setCurrentTopics(undefined);
|
||||
updateTopicFilter(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCurrentTopics(selectedTopics);
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCurrentTopics(selectedTopics);
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>{content}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type TopicFilterContentProps = {
|
||||
currentTopics: TopicCategory[] | undefined;
|
||||
setCurrentTopics: (topics: TopicCategory[] | undefined) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
function TopicFilterContent({
|
||||
currentTopics,
|
||||
setCurrentTopics,
|
||||
onApply,
|
||||
onReset,
|
||||
}: TopicFilterContentProps) {
|
||||
const { t } = useTranslation(["views/system", "common"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5 p-4">
|
||||
<FilterSwitch
|
||||
isChecked={currentTopics === undefined}
|
||||
label={t("logs.websocket.filter.all")}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentTopics(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
{ALL_TOPIC_CATEGORIES.map((cat) => (
|
||||
<FilterSwitch
|
||||
key={cat}
|
||||
isChecked={currentTopics?.includes(cat) ?? false}
|
||||
label={t(`logs.websocket.filter.${cat}`)}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updated = currentTopics ? [...currentTopics, cat] : [cat];
|
||||
setCurrentTopics(updated);
|
||||
} else {
|
||||
const updated = currentTopics
|
||||
? currentTopics.filter((c) => c !== cat)
|
||||
: [];
|
||||
if (updated.length === 0) {
|
||||
setCurrentTopics(undefined);
|
||||
} else {
|
||||
setCurrentTopics(updated);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
aria-label={t("button.apply", { ns: "common" })}
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={onApply}
|
||||
>
|
||||
{t("button.apply", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Camera Filter Button
|
||||
|
||||
type WsCamerasFilterButtonProps = {
|
||||
allCameras: string[];
|
||||
selectedCameras: string[] | undefined;
|
||||
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||
};
|
||||
|
||||
function WsCamerasFilterButton({
|
||||
allCameras,
|
||||
selectedCameras,
|
||||
updateCameraFilter,
|
||||
}: WsCamerasFilterButtonProps) {
|
||||
const { t } = useTranslation(["views/system", "common"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
|
||||
selectedCameras,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentCameras(selectedCameras);
|
||||
}, [selectedCameras]);
|
||||
|
||||
const isFiltered = selectedCameras !== undefined;
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant={isFiltered ? "select" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
aria-label={t("logs.websocket.filter.all_cameras")}
|
||||
>
|
||||
<FaVideo
|
||||
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
||||
/>
|
||||
<span className={isFiltered ? "text-selected-foreground" : ""}>
|
||||
{!selectedCameras
|
||||
? t("logs.websocket.filter.all_cameras")
|
||||
: t("logs.websocket.filter.cameras_count", {
|
||||
count: selectedCameras.length,
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<WsCamerasFilterContent
|
||||
allCameras={allCameras}
|
||||
currentCameras={currentCameras}
|
||||
setCurrentCameras={setCurrentCameras}
|
||||
onApply={() => {
|
||||
updateCameraFilter(currentCameras);
|
||||
setOpen(false);
|
||||
}}
|
||||
onReset={() => {
|
||||
setCurrentCameras(undefined);
|
||||
updateCameraFilter(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCurrentCameras(selectedCameras);
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCurrentCameras(selectedCameras);
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>{content}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type WsCamerasFilterContentProps = {
|
||||
allCameras: string[];
|
||||
currentCameras: string[] | undefined;
|
||||
setCurrentCameras: (cameras: string[] | undefined) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
function WsCamerasFilterContent({
|
||||
allCameras,
|
||||
currentCameras,
|
||||
setCurrentCameras,
|
||||
onApply,
|
||||
onReset,
|
||||
}: WsCamerasFilterContentProps) {
|
||||
const { t } = useTranslation(["views/system", "common"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="scrollbar-container flex max-h-[60dvh] flex-col gap-2.5 overflow-y-auto p-4">
|
||||
<FilterSwitch
|
||||
isChecked={currentCameras === undefined}
|
||||
label={t("logs.websocket.filter.all_cameras")}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
setCurrentCameras(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
{allCameras.map((cam) => (
|
||||
<FilterSwitch
|
||||
key={cam}
|
||||
isChecked={currentCameras?.includes(cam) ?? false}
|
||||
label={cam}
|
||||
type="camera"
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
const updated = currentCameras ? [...currentCameras] : [];
|
||||
if (!updated.includes(cam)) {
|
||||
updated.push(cam);
|
||||
}
|
||||
setCurrentCameras(updated);
|
||||
} else {
|
||||
const updated = currentCameras ? [...currentCameras] : [];
|
||||
if (updated.length > 1) {
|
||||
updated.splice(updated.indexOf(cam), 1);
|
||||
setCurrentCameras(updated);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-evenly p-2">
|
||||
<Button
|
||||
aria-label={t("button.apply", { ns: "common" })}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={currentCameras?.length === 0}
|
||||
onClick={onApply}
|
||||
>
|
||||
{t("button.apply", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("button.reset", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
433
web/src/components/ws/WsMessageRow.tsx
Normal file
433
web/src/components/ws/WsMessageRow.tsx
Normal file
@ -0,0 +1,433 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { WsFeedMessage } from "@/api/ws";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractCameraName } from "@/utils/wsUtil";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||
|
||||
type TopicCategory = "events" | "camera_activity" | "system" | "other";
|
||||
|
||||
const TOPIC_CATEGORY_COLORS: Record<TopicCategory, string> = {
|
||||
events: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
|
||||
camera_activity:
|
||||
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
|
||||
system:
|
||||
"bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
|
||||
other: "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
|
||||
};
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
start:
|
||||
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
|
||||
update: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
|
||||
end: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
|
||||
};
|
||||
|
||||
const TRACKED_OBJECT_UPDATE_COLORS: Record<string, string> = {
|
||||
description:
|
||||
"bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30",
|
||||
face: "bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30",
|
||||
lpr: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30",
|
||||
classification:
|
||||
"bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30",
|
||||
};
|
||||
|
||||
function getEventTypeColor(eventType: string): string {
|
||||
return (
|
||||
EVENT_TYPE_COLORS[eventType] ||
|
||||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
|
||||
);
|
||||
}
|
||||
|
||||
function getTrackedObjectTypeColor(objectType: string): string {
|
||||
return (
|
||||
TRACKED_OBJECT_UPDATE_COLORS[objectType] ||
|
||||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
|
||||
);
|
||||
}
|
||||
|
||||
const EVENT_TOPICS = new Set([
|
||||
"events",
|
||||
"reviews",
|
||||
"tracked_object_update",
|
||||
"triggers",
|
||||
]);
|
||||
|
||||
const SYSTEM_TOPICS = new Set([
|
||||
"stats",
|
||||
"model_state",
|
||||
"job_state",
|
||||
"embeddings_reindex_progress",
|
||||
"audio_transcription_state",
|
||||
"birdseye_layout",
|
||||
]);
|
||||
|
||||
function getTopicCategory(topic: string): TopicCategory {
|
||||
if (EVENT_TOPICS.has(topic)) return "events";
|
||||
if (SYSTEM_TOPICS.has(topic)) return "system";
|
||||
if (
|
||||
topic === "camera_activity" ||
|
||||
topic === "audio_detections" ||
|
||||
topic.includes("/motion") ||
|
||||
topic.includes("/audio") ||
|
||||
topic.includes("/detect") ||
|
||||
topic.includes("/recordings") ||
|
||||
topic.includes("/enabled") ||
|
||||
topic.includes("/snapshots") ||
|
||||
topic.includes("/ptz")
|
||||
) {
|
||||
return "camera_activity";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
function getPayloadSummary(
|
||||
topic: string,
|
||||
payload: unknown,
|
||||
hideType: boolean = false,
|
||||
): string {
|
||||
if (payload === null || payload === undefined) return "";
|
||||
|
||||
try {
|
||||
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
// Topic-specific summary handlers
|
||||
if (topic === "tracked_object_update") {
|
||||
return getTrackedObjectUpdateSummary(data);
|
||||
}
|
||||
|
||||
if ("type" in data && "label" in (data.after || data)) {
|
||||
const after = data.after || data;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (!hideType) {
|
||||
parts.push(`type: ${data.type}`);
|
||||
}
|
||||
parts.push(`label: ${after.label || "?"}`);
|
||||
|
||||
// Add sub_label for events topic if present
|
||||
if (topic === "events" && after.sub_label) {
|
||||
parts.push(`sub_label: ${after.sub_label}`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
if ("type" in data && "camera" in data) {
|
||||
if (hideType) {
|
||||
return `camera: ${data.camera}`;
|
||||
}
|
||||
return `type: ${data.type}, camera: ${data.camera}`;
|
||||
}
|
||||
const keys = Object.keys(data);
|
||||
if (keys.length <= 3) {
|
||||
return keys
|
||||
.map((k) => {
|
||||
const v = data[k];
|
||||
if (typeof v === "string" || typeof v === "number") {
|
||||
return `${k}: ${v}`;
|
||||
}
|
||||
return k;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
return `{${keys.length} keys}`;
|
||||
}
|
||||
|
||||
const str = String(data);
|
||||
return str.length > 80 ? str.slice(0, 80) + "…" : str;
|
||||
} catch {
|
||||
const str = String(payload);
|
||||
return str.length > 80 ? str.slice(0, 80) + "…" : str;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackedObjectUpdateSummary(data: unknown): string {
|
||||
if (typeof data !== "object" || data === null) return "";
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
const type = obj.type as string;
|
||||
|
||||
switch (type) {
|
||||
case "description":
|
||||
return obj.description ? `${obj.description}` : "no description";
|
||||
|
||||
case "face": {
|
||||
const name = obj.name as string | undefined;
|
||||
return name || "unknown";
|
||||
}
|
||||
|
||||
case "lpr": {
|
||||
const name = obj.name as string | undefined;
|
||||
const plate = obj.plate as string | undefined;
|
||||
return name || plate || "unknown";
|
||||
}
|
||||
|
||||
case "classification": {
|
||||
const parts: string[] = [];
|
||||
const model = obj.model as string | undefined;
|
||||
const subLabel = obj.sub_label as string | undefined;
|
||||
const attribute = obj.attribute as string | undefined;
|
||||
|
||||
if (model) parts.push(`model: ${model}`);
|
||||
if (subLabel) parts.push(`sub_label: ${subLabel}`);
|
||||
if (attribute) parts.push(`attribute: ${attribute}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "classification";
|
||||
}
|
||||
|
||||
default:
|
||||
return type || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function extractTypeForBadge(payload: unknown): string | null {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
|
||||
try {
|
||||
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
|
||||
if (typeof data === "object" && data !== null && "type" in data) {
|
||||
return data.type as string;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldShowTypeBadge(type: string | null): boolean {
|
||||
if (!type) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldShowSummary(topic: string): boolean {
|
||||
// Hide summary for reviews topic
|
||||
return topic !== "reviews";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function highlightJson(value: unknown): string {
|
||||
// Try to auto-parse JSON strings
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
value = parsed;
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
|
||||
const raw = JSON.stringify(value, null, 2) ?? String(value);
|
||||
|
||||
// Single regex pass to colorize JSON tokens
|
||||
return raw.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match, key: string, str: string, keyword: string, num: string) => {
|
||||
if (key) {
|
||||
return `<span class="text-indigo-400">${escapeHtml(key)}</span>:`;
|
||||
}
|
||||
if (str) {
|
||||
const content = escapeHtml(str);
|
||||
return `<span class="text-green-500">${content}</span>`;
|
||||
}
|
||||
if (keyword) {
|
||||
return `<span class="text-orange-500">${keyword}</span>`;
|
||||
}
|
||||
if (num) {
|
||||
return `<span class="text-cyan-500">${num}</span>`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function CopyJsonButton({ payload }: { payload: unknown }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const text =
|
||||
typeof payload === "string"
|
||||
? payload
|
||||
: JSON.stringify(payload, null, 2);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
},
|
||||
[payload],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Copy JSON"
|
||||
>
|
||||
{copied ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuCopy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type WsMessageRowProps = {
|
||||
message: WsFeedMessage;
|
||||
showCameraBadge?: boolean;
|
||||
};
|
||||
|
||||
const WsMessageRow = memo(function WsMessageRow({
|
||||
message,
|
||||
showCameraBadge = true,
|
||||
}: WsMessageRowProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const category = getTopicCategory(message.topic);
|
||||
|
||||
const cameraName = extractCameraName(message);
|
||||
|
||||
const messageType = extractTypeForBadge(message.payload);
|
||||
const showTypeBadge = shouldShowTypeBadge(messageType);
|
||||
|
||||
const summary = getPayloadSummary(message.topic, message.payload);
|
||||
|
||||
const eventLabel = (() => {
|
||||
try {
|
||||
const data =
|
||||
typeof message.payload === "string"
|
||||
? JSON.parse(message.payload)
|
||||
: message.payload;
|
||||
if (typeof data === "object" && data !== null) {
|
||||
return (data.after?.label as string) || (data.label as string) || null;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const parsedPayload = (() => {
|
||||
try {
|
||||
return typeof message.payload === "string"
|
||||
? JSON.parse(message.payload)
|
||||
: message.payload;
|
||||
} catch {
|
||||
return message.payload;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Determine which color function to use based on topic
|
||||
const getTypeBadgeColor = (type: string | null) => {
|
||||
if (!type) return "";
|
||||
if (message.topic === "tracked_object_update") {
|
||||
return getTrackedObjectTypeColor(type);
|
||||
}
|
||||
return getEventTypeColor(type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-secondary/50">
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/50",
|
||||
expanded && "bg-muted/30",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs",
|
||||
TOPIC_CATEGORY_COLORS[category],
|
||||
)}
|
||||
>
|
||||
{message.topic}
|
||||
</span>
|
||||
|
||||
{showTypeBadge && messageType && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1.5 py-0.5 text-xs",
|
||||
getTypeBadgeColor(messageType),
|
||||
)}
|
||||
>
|
||||
{messageType}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showCameraBadge && cameraName && (
|
||||
<span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
||||
{cameraName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{eventLabel && (
|
||||
<span className="shrink-0">
|
||||
{getIconForLabel(
|
||||
eventLabel,
|
||||
"object",
|
||||
"size-3.5 text-primary-variant",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{shouldShowSummary(message.topic) && (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-secondary/30 bg-background_alt/50 px-4 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t("logs.websocket.expanded.payload")}
|
||||
</span>
|
||||
<CopyJsonButton payload={parsedPayload} />
|
||||
</div>
|
||||
<pre
|
||||
className="scrollbar-container max-h-[60vh] overflow-auto rounded bg-background p-2 font-mono text-[11px] leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: highlightJson(parsedPayload) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WsMessageRow;
|
||||
@ -2,6 +2,7 @@ import { useContext } from "react";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
|
||||
export function useAllowedCameras() {
|
||||
const { auth } = useContext(AuthContext);
|
||||
@ -14,9 +15,11 @@ export function useAllowedCameras() {
|
||||
auth.user?.role === "admin" ||
|
||||
!auth.isAuthenticated // anonymous internal port
|
||||
) {
|
||||
// return all cameras
|
||||
return config?.cameras ? Object.keys(config.cameras) : [];
|
||||
// return all cameras, excluding replay cameras
|
||||
return config?.cameras
|
||||
? Object.keys(config.cameras).filter((name) => !isReplayCamera(name))
|
||||
: [];
|
||||
}
|
||||
|
||||
return auth.allowedCameras || [];
|
||||
return (auth.allowedCameras || []).filter((name) => !isReplayCamera(name));
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ type useCameraActivityReturn = {
|
||||
};
|
||||
|
||||
export function useCameraActivity(
|
||||
camera: CameraConfig,
|
||||
camera: CameraConfig | undefined,
|
||||
revalidateOnFocus: boolean = true,
|
||||
): useCameraActivityReturn {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@ -47,7 +47,7 @@ export function useCameraActivity(
|
||||
// init camera activity
|
||||
|
||||
const { payload: updatedCameraState } = useInitialCameraState(
|
||||
camera.name,
|
||||
camera?.name ?? "",
|
||||
revalidateOnFocus,
|
||||
);
|
||||
useEffect(() => {
|
||||
@ -60,7 +60,7 @@ export function useCameraActivity(
|
||||
const memoizedAudioState = useDeepMemo(updatedAudioState);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoizedAudioState) {
|
||||
if (memoizedAudioState && camera?.name) {
|
||||
setAudioDetections(memoizedAudioState[camera.name]);
|
||||
}
|
||||
}, [memoizedAudioState, camera]);
|
||||
@ -72,8 +72,8 @@ export function useCameraActivity(
|
||||
[objects],
|
||||
);
|
||||
|
||||
const { payload: cameraEnabled } = useEnabledState(camera.name);
|
||||
const { payload: detectingMotion } = useMotionActivity(camera.name);
|
||||
const { payload: cameraEnabled } = useEnabledState(camera?.name ?? "");
|
||||
const { payload: detectingMotion } = useMotionActivity(camera?.name ?? "");
|
||||
const { payload: event } = useFrigateEvents();
|
||||
const updatedEvent = useDeepMemo(event);
|
||||
|
||||
@ -91,7 +91,7 @@ export function useCameraActivity(
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedEvent.after.camera !== camera.name) {
|
||||
if (!camera?.name || updatedEvent.after.camera !== camera.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -158,6 +158,10 @@ export function useCameraActivity(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!camera?.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
|
||||
);
|
||||
|
||||
@ -9,6 +9,7 @@ import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -16,6 +17,9 @@ import { useTranslation } from "react-i18next";
|
||||
export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: debugReplayStatus } = useSWR("debug_replay/status", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const memoizedStats = useDeepMemo(stats);
|
||||
|
||||
@ -74,6 +78,11 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip replay cameras
|
||||
if (isReplayCamera(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
|
||||
problems.push({
|
||||
@ -96,7 +105,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
);
|
||||
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
|
||||
// Skip ffmpeg warnings for replay cameras when debug replay is active
|
||||
if (
|
||||
!isNaN(ffmpegAvg) &&
|
||||
ffmpegAvg >= CameraFfmpegThreshold.error &&
|
||||
!(
|
||||
debugReplayStatus?.active && debugReplayStatus?.replay_camera === name
|
||||
)
|
||||
) {
|
||||
problems.push({
|
||||
text: t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
@ -119,8 +136,19 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
if (debugReplayStatus?.active) {
|
||||
problems.push({
|
||||
text: t("stats.debugReplayActive", {
|
||||
defaultValue: "Debug replay session is active",
|
||||
}),
|
||||
color: "text-selected",
|
||||
relevantLink: "/replay",
|
||||
});
|
||||
}
|
||||
|
||||
return problems;
|
||||
}, [config, memoizedStats, t]);
|
||||
}, [config, memoizedStats, t, debugReplayStatus]);
|
||||
|
||||
return { potentialProblems };
|
||||
}
|
||||
|
||||
99
web/src/hooks/use-ws-message-buffer.ts
Normal file
99
web/src/hooks/use-ws-message-buffer.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useWsMessageSubscribe, WsFeedMessage } from "@/api/ws";
|
||||
import { extractCameraName } from "@/utils/wsUtil";
|
||||
|
||||
type UseWsMessageBufferReturn = {
|
||||
messages: WsFeedMessage[];
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
type MessageFilter = {
|
||||
cameraFilter?: string | string[]; // "all", specific camera name, or array of camera names (undefined in array = all)
|
||||
};
|
||||
|
||||
export function useWsMessageBuffer(
|
||||
maxSize: number = 2000,
|
||||
paused: boolean = false,
|
||||
filter?: MessageFilter,
|
||||
): UseWsMessageBufferReturn {
|
||||
const bufferRef = useRef<WsFeedMessage[]>([]);
|
||||
const [version, setVersion] = useState(0);
|
||||
const pausedRef = useRef(paused);
|
||||
const filterRef = useRef(filter);
|
||||
|
||||
pausedRef.current = paused;
|
||||
filterRef.current = filter;
|
||||
|
||||
const batchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
batchTimerRef.current = setInterval(() => {
|
||||
if (dirtyRef.current) {
|
||||
dirtyRef.current = false;
|
||||
setVersion((v) => v + 1);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (batchTimerRef.current) {
|
||||
clearInterval(batchTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldIncludeMessage = useCallback((msg: WsFeedMessage): boolean => {
|
||||
const currentFilter = filterRef.current;
|
||||
if (!currentFilter) return true;
|
||||
|
||||
// Check camera filter
|
||||
const cf = currentFilter.cameraFilter;
|
||||
if (cf !== undefined) {
|
||||
if (Array.isArray(cf)) {
|
||||
// Array of cameras: include messages matching any camera in the list
|
||||
const msgCamera = extractCameraName(msg);
|
||||
if (msgCamera && !cf.includes(msgCamera)) {
|
||||
return false;
|
||||
}
|
||||
} else if (cf !== "all") {
|
||||
// Single string camera filter
|
||||
const msgCamera = extractCameraName(msg);
|
||||
if (msgCamera !== cf) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useWsMessageSubscribe(
|
||||
useCallback(
|
||||
(msg: WsFeedMessage) => {
|
||||
if (pausedRef.current) return;
|
||||
if (!shouldIncludeMessage(msg)) return;
|
||||
|
||||
const buf = bufferRef.current;
|
||||
buf.push(msg);
|
||||
if (buf.length > maxSize) {
|
||||
buf.splice(0, buf.length - maxSize);
|
||||
}
|
||||
dirtyRef.current = true;
|
||||
},
|
||||
[shouldIncludeMessage, maxSize],
|
||||
),
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
bufferRef.current = [];
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// version is used to trigger re-renders; we spread the buffer
|
||||
// into a new array so that downstream useMemo dependencies
|
||||
// see a new reference and recompute.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const messages = useMemo(() => [...bufferRef.current], [version]);
|
||||
|
||||
return { messages, clear };
|
||||
}
|
||||
@ -642,4 +642,4 @@ function CaseAssignmentDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export default Exports;
|
||||
export default Exports;
|
||||
|
||||
@ -35,10 +35,12 @@ import { isIOS, isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { isInIframe } from "@/utils/isIFrame";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||
|
||||
function Logs() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [logService, setLogService] = useState<LogType>("frigate");
|
||||
const isWebsocket = logService === "websocket";
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
@ -216,6 +218,12 @@ function Logs() {
|
||||
}, [logService, filterSeverity, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWebsocket) {
|
||||
setIsLoading(false);
|
||||
setLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLogs([]);
|
||||
lastFetchedIndexRef.current = -1;
|
||||
@ -494,116 +502,128 @@ function Logs() {
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
<div className="smart-capitalize">{item}</div>
|
||||
<div
|
||||
className={item !== "websocket" ? "smart-capitalize" : ""}
|
||||
>
|
||||
{item === "websocket" ? t("logs.websocket.label") : item}
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label={t("logs.copy.label")}
|
||||
size="sm"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
<FaCopy className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">
|
||||
{t("logs.copy.label")}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label={t("logs.download.label")}
|
||||
size="sm"
|
||||
onClick={handleDownloadLogs}
|
||||
>
|
||||
<FaDownload className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">
|
||||
{t("button.download", { ns: "common" })}
|
||||
</div>
|
||||
</Button>
|
||||
<LogSettingsButton
|
||||
selectedLabels={filterSeverity}
|
||||
updateLabelFilter={setFilterSeverity}
|
||||
logSettings={logSettings}
|
||||
setLogSettings={setLogSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
|
||||
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
||||
<div className="col-span-3 lg:col-span-2">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
|
||||
{t("logs.type.label")}
|
||||
{!isWebsocket && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label={t("logs.copy.label")}
|
||||
size="sm"
|
||||
onClick={handleCopyLogs}
|
||||
>
|
||||
<FaCopy className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">
|
||||
{t("logs.copy.label")}
|
||||
</div>
|
||||
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-label={t("logs.download.label")}
|
||||
size="sm"
|
||||
onClick={handleDownloadLogs}
|
||||
>
|
||||
<FaDownload className="text-secondary-foreground" />
|
||||
<div className="hidden text-primary md:block">
|
||||
{t("button.download", { ns: "common" })}
|
||||
</div>
|
||||
</Button>
|
||||
<LogSettingsButton
|
||||
selectedLabels={filterSeverity}
|
||||
updateLabelFilter={setFilterSeverity}
|
||||
logSettings={logSettings}
|
||||
setLogSettings={setLogSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWebsocket ? (
|
||||
<div className="my-2 flex size-full flex-col overflow-hidden rounded-md border border-secondary bg-background_alt">
|
||||
<WsMessageFeed maxSize={2000} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
|
||||
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
|
||||
<div className="col-span-3 lg:col-span-2">
|
||||
<div className="flex w-full flex-row items-center">
|
||||
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
|
||||
{t("logs.type.label")}
|
||||
</div>
|
||||
<div className="mr-3">{t("logs.type.timestamp")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
logService == "frigate" ? "col-span-2" : "col-span-1",
|
||||
)}
|
||||
>
|
||||
{t("logs.type.tag")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-5 flex items-center",
|
||||
logService == "frigate"
|
||||
? "md:col-span-7 lg:col-span-8"
|
||||
: "md:col-span-8 lg:col-span-9",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1">{t("logs.type.message")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
logService == "frigate" ? "col-span-2" : "col-span-1",
|
||||
)}
|
||||
>
|
||||
{t("logs.type.tag")}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-5 flex items-center",
|
||||
logService == "frigate"
|
||||
? "md:col-span-7 lg:col-span-8"
|
||||
: "md:col-span-8 lg:col-span-9",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1">{t("logs.type.message")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={lazyLogWrapperRef} className="size-full">
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<EnhancedScrollFollow
|
||||
startFollowing={!isLoading}
|
||||
onCustomScroll={handleScroll}
|
||||
render={({ follow, onScroll }) => (
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<LazyLog
|
||||
ref={lazyLogRef}
|
||||
enableLineNumbers={false}
|
||||
selectableLines
|
||||
lineClassName="text-primary bg-background"
|
||||
highlightLineClassName="bg-primary/20"
|
||||
onRowClick={handleRowClick}
|
||||
formatPart={formatPart}
|
||||
text={logs.join("\n")}
|
||||
follow={follow}
|
||||
onScroll={onScroll}
|
||||
loadingComponent={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div ref={lazyLogWrapperRef} className="size-full">
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<EnhancedScrollFollow
|
||||
startFollowing={!isLoading}
|
||||
onCustomScroll={handleScroll}
|
||||
render={({ follow, onScroll }) => (
|
||||
<>
|
||||
{follow && !logSettings.disableStreaming && (
|
||||
<div className="absolute right-1 top-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.tips")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<LazyLog
|
||||
ref={lazyLogRef}
|
||||
enableLineNumbers={false}
|
||||
selectableLines
|
||||
lineClassName="text-primary bg-background"
|
||||
highlightLineClassName="bg-primary/20"
|
||||
onRowClick={handleRowClick}
|
||||
formatPart={formatPart}
|
||||
text={logs.join("\n")}
|
||||
follow={follow}
|
||||
onScroll={onScroll}
|
||||
loadingComponent={
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
725
web/src/pages/Replay.tsx
Normal file
725
web/src/pages/Replay.tsx
Normal file
@ -0,0 +1,725 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { ObjectType } from "@/types/ws";
|
||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
|
||||
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
|
||||
import { LuSquare } from "react-icons/lu";
|
||||
import { MdReplay } from "react-icons/md";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import Logo from "@/components/Logo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
|
||||
type DebugReplayStatus = {
|
||||
active: boolean;
|
||||
replay_camera: string | null;
|
||||
source_camera: string | null;
|
||||
start_time: number | null;
|
||||
end_time: number | null;
|
||||
live_ready: boolean;
|
||||
};
|
||||
|
||||
type DebugOptions = {
|
||||
bbox: boolean;
|
||||
timestamp: boolean;
|
||||
zones: boolean;
|
||||
mask: boolean;
|
||||
motion: boolean;
|
||||
regions: boolean;
|
||||
paths: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: DebugOptions = {
|
||||
bbox: true,
|
||||
timestamp: false,
|
||||
zones: false,
|
||||
mask: false,
|
||||
motion: true,
|
||||
regions: false,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [
|
||||
"bbox",
|
||||
"timestamp",
|
||||
"zones",
|
||||
"mask",
|
||||
"motion",
|
||||
"regions",
|
||||
"paths",
|
||||
];
|
||||
|
||||
const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
|
||||
bbox: "boundingBoxes",
|
||||
timestamp: "timestamp",
|
||||
zones: "zones",
|
||||
mask: "mask",
|
||||
motion: "motion",
|
||||
regions: "regions",
|
||||
paths: "paths",
|
||||
};
|
||||
|
||||
const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000;
|
||||
|
||||
export default function Replay() {
|
||||
const { t } = useTranslation(["views/replay", "views/settings", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const {
|
||||
data: status,
|
||||
mutate: refreshStatus,
|
||||
isLoading,
|
||||
} = useSWR<DebugReplayStatus>("debug_replay/status", {
|
||||
refreshInterval: 1000,
|
||||
});
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
||||
// Refresh status immediately on mount to avoid showing "no session" briefly
|
||||
useEffect(() => {
|
||||
const initializeStatus = async () => {
|
||||
await refreshStatus();
|
||||
setIsInitializing(false);
|
||||
};
|
||||
initializeStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.live_ready) {
|
||||
setShowReplayInitSkeleton(false);
|
||||
}
|
||||
}, [status?.live_ready]);
|
||||
|
||||
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
|
||||
const searchParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of DEBUG_OPTION_KEYS) {
|
||||
params.set(key, options[key] ? "1" : "0");
|
||||
}
|
||||
return params;
|
||||
}, [options]);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
(key: keyof DebugOptions, value: boolean) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
setIsStopping(true);
|
||||
axios
|
||||
.post("debug_replay/stop")
|
||||
.then(() => {
|
||||
toast.success(t("dialog.toast.stopped"), {
|
||||
position: "top-center",
|
||||
});
|
||||
refreshStatus();
|
||||
navigate("/review");
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("dialog.toast.stopError", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStopping(false);
|
||||
});
|
||||
}, [navigate, refreshStatus, t]);
|
||||
|
||||
// Camera activity for the replay camera
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const replayCameraName = status?.replay_camera ?? "";
|
||||
const replayCameraConfig = replayCameraName
|
||||
? config?.cameras?.[replayCameraName]
|
||||
: undefined;
|
||||
|
||||
const { objects } = useCameraActivity(replayCameraConfig);
|
||||
|
||||
const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false);
|
||||
|
||||
// debug draw
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [debugDraw, setDebugDraw] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.active || !status.replay_camera) {
|
||||
setShowReplayInitSkeleton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowReplayInitSkeleton(true);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setShowReplayInitSkeleton(false);
|
||||
}, REPLAY_INIT_SKELETON_TIMEOUT_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [status?.active, status?.replay_camera]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.live_ready) {
|
||||
setShowReplayInitSkeleton(false);
|
||||
}
|
||||
}, [status?.live_ready]);
|
||||
|
||||
// Format time range for display
|
||||
const timeRangeDisplay = useMemo(() => {
|
||||
if (!status?.start_time || !status?.end_time) return "";
|
||||
const start = new Date(status.start_time * 1000).toLocaleString();
|
||||
const end = new Date(status.end_time * 1000).toLocaleString();
|
||||
return `${start} — ${end}`;
|
||||
}, [status]);
|
||||
|
||||
// Show loading state
|
||||
if (isInitializing || (isLoading && !status?.active)) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No active session
|
||||
if (!status?.active) {
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
||||
<MdReplay className="size-12" />
|
||||
<Heading as="h2" className="text-center">
|
||||
{t("page.noSession")}
|
||||
</Heading>
|
||||
<p className="max-w-md text-center text-muted-foreground">
|
||||
{t("page.noSessionDesc")}
|
||||
</p>
|
||||
<Button variant="default" onClick={() => navigate("/review")}>
|
||||
{t("page.goToRecordings")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="flex min-h-12 items-center justify-between border-b border-secondary px-2 py-2 md:min-h-16 md:px-3 md:py-3">
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
>
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">{t("page.configuration")}</span>
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-white"
|
||||
disabled={isStopping}
|
||||
>
|
||||
{isStopping && <ActivityIndicator className="size-4" />}
|
||||
<span className="hidden md:inline">{t("page.stopReplay")}</span>
|
||||
<LuSquare className="size-4 md:hidden" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("page.confirmStop.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("page.confirmStop.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("page.confirmStop.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleStop}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "destructive" }),
|
||||
"text-white",
|
||||
)}
|
||||
>
|
||||
{t("page.confirmStop.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden pb-2 md:flex-row">
|
||||
{/* Camera feed */}
|
||||
<div className="flex max-h-[40%] px-2 pt-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4 md:pt-2">
|
||||
{isStopping ? (
|
||||
<div className="flex size-full items-center justify-center rounded-lg bg-background_alt">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<ActivityIndicator className="size-8" />
|
||||
<div className="text-secondary-foreground">
|
||||
{t("page.stoppingReplay")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
status.replay_camera && (
|
||||
<div className="relative size-full min-h-10" ref={containerRef}>
|
||||
<AutoUpdatingCameraImage
|
||||
className="size-full"
|
||||
cameraClasses="relative w-full h-full flex flex-col justify-start"
|
||||
searchParams={searchParams}
|
||||
camera={status.replay_camera}
|
||||
showFps={false}
|
||||
/>
|
||||
{debugDraw && (
|
||||
<DebugDrawingLayer
|
||||
containerRef={containerRef}
|
||||
cameraWidth={
|
||||
config?.cameras?.[status.source_camera ?? ""]?.detect
|
||||
.width ?? 1280
|
||||
}
|
||||
cameraHeight={
|
||||
config?.cameras?.[status.source_camera ?? ""]?.detect
|
||||
.height ?? 720
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showReplayInitSkeleton && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background">
|
||||
<Skeleton className="size-full rounded-lg" />
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2">
|
||||
<ActivityIndicator className="size-8" />
|
||||
<div className="text-secondary-foreground">
|
||||
{t("page.initializingReplay")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-4/12">
|
||||
<div className="mb-5 flex flex-col space-y-2">
|
||||
<Heading as="h3" className="mb-0">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="smart-capitalize">{status.source_camera}</span>
|
||||
{timeRangeDisplay && (
|
||||
<>
|
||||
<span className="hidden md:inline">•</span>
|
||||
<span className="hidden md:inline">{timeRangeDisplay}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
||||
<p>{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="debug" className="flex h-full w-full flex-col">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="debug">
|
||||
{t("debug.debugging", { ns: "views/settings" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="objects">{t("page.objects")}</TabsTrigger>
|
||||
<TabsTrigger value="messages">
|
||||
{t("websocket_messages")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="debug" className="mt-2">
|
||||
<div className="mt-2 space-y-6">
|
||||
<div className="my-2.5 flex flex-col gap-2.5">
|
||||
{DEBUG_OPTION_KEYS.map((key) => {
|
||||
const i18nKey = DEBUG_OPTION_I18N_KEY[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex w-full flex-row items-center justify-between"
|
||||
>
|
||||
<div className="mb-1 flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor={`debug-${key}`}
|
||||
>
|
||||
{t(`debug.${i18nKey}.title`, {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Label>
|
||||
{(key === "bbox" ||
|
||||
key === "motion" ||
|
||||
key === "regions" ||
|
||||
key === "paths") && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm">
|
||||
{key === "bbox" ? (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
<strong>
|
||||
{t(
|
||||
"debug.boundingBoxes.colors.label",
|
||||
{
|
||||
ns: "views/settings",
|
||||
},
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<Trans ns="views/settings">
|
||||
debug.boundingBoxes.colors.info
|
||||
</Trans>
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<Trans ns="views/settings">
|
||||
{`debug.${i18nKey}.tips`}
|
||||
</Trans>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`debug.${i18nKey}.desc`, {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id={`debug-${key}`}
|
||||
className="ml-1"
|
||||
checked={options[key]}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSetOption(key, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isDesktop && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<div className="mb-2 flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
className="mb-0 cursor-pointer text-primary smart-capitalize"
|
||||
htmlFor="debugdraw"
|
||||
>
|
||||
{t("debug.objectShapeFilterDrawing.title", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</Label>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="cursor-pointer p-0">
|
||||
<LuInfo className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("button.info", { ns: "common" })}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm">
|
||||
{t("debug.objectShapeFilterDrawing.tips", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/object_filters#object-shape",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", {
|
||||
ns: "common",
|
||||
})}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("debug.objectShapeFilterDrawing.desc", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
key={"draw"}
|
||||
className="ml-1"
|
||||
id="debug_draw"
|
||||
checked={debugDraw}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setDebugDraw(isChecked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="objects" className="mt-2">
|
||||
<ObjectList
|
||||
cameraConfig={replayCameraConfig}
|
||||
objects={objects}
|
||||
config={config}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="messages"
|
||||
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary">
|
||||
<WsMessageFeed
|
||||
maxSize={2000}
|
||||
lockedCamera={status.replay_camera ?? undefined}
|
||||
showCameraBadge={false}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
|
||||
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("page.configuration")}</DialogTitle>
|
||||
<DialogDescription className="mb-5">
|
||||
{t("page.configurationDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="motion"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
<ConfigSectionTemplate
|
||||
sectionKey="objects"
|
||||
level="replay"
|
||||
cameraName={status.replay_camera ?? undefined}
|
||||
skipSave
|
||||
noStickyButtons
|
||||
requiresRestart={false}
|
||||
collapsible
|
||||
defaultCollapsed={false}
|
||||
showTitle
|
||||
showOverrideIndicator={false}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ObjectListProps = {
|
||||
cameraConfig?: CameraConfig;
|
||||
objects?: ObjectType[];
|
||||
config?: FrigateConfig;
|
||||
};
|
||||
|
||||
function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const colormap = useMemo(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
return config.model?.colormap;
|
||||
}, [config]);
|
||||
|
||||
const getColorForObjectName = useCallback(
|
||||
(objectName: string) => {
|
||||
return colormap && colormap[objectName]
|
||||
? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})`
|
||||
: "rgb(128, 128, 128)";
|
||||
},
|
||||
[colormap],
|
||||
);
|
||||
|
||||
if (!objects || objects.length === 0) {
|
||||
return (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{t("debug.noObjects", { ns: "views/settings" })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{objects.map((obj: ObjectType) => {
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
className="flex flex-col rounded-lg bg-secondary/30 p-2"
|
||||
>
|
||||
<div className="flex flex-row items-center gap-3 pb-1">
|
||||
<div
|
||||
className="rounded-lg p-2"
|
||||
style={{
|
||||
backgroundColor: obj.stationary
|
||||
? "rgb(110,110,110)"
|
||||
: getColorForObjectName(obj.label),
|
||||
}}
|
||||
>
|
||||
{getIconForLabel(obj.label, "object", "size-4 text-white")}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{getTranslatedLabel(obj.label)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pl-1 text-xs text-primary-variant">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{t("debug.objectShapeFilterDrawing.score", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<span className="text-primary">
|
||||
{obj.score ? (obj.score * 100).toFixed(1) : "-"}%
|
||||
</span>
|
||||
</div>
|
||||
{obj.ratio && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{t("debug.objectShapeFilterDrawing.ratio", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<span className="text-primary">{obj.ratio.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{obj.area && cameraConfig && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{t("debug.objectShapeFilterDrawing.area", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
:
|
||||
</span>
|
||||
<span className="text-primary">
|
||||
{obj.area} px (
|
||||
{(
|
||||
(obj.area /
|
||||
(cameraConfig.detect.width *
|
||||
cameraConfig.detect.height)) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ export type LogLine = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
||||
export const logTypes = ["frigate", "go2rtc", "nginx", "websocket"] as const;
|
||||
export type LogType = (typeof logTypes)[number];
|
||||
|
||||
export type LogSettingsType = {
|
||||
|
||||
@ -148,3 +148,15 @@ export function detectCameraAudioFeatures(
|
||||
audioOutput: !!audioOutput,
|
||||
};
|
||||
}
|
||||
|
||||
const REPLAY_CAMERA_PREFIX = "_replay_";
|
||||
|
||||
/**
|
||||
* Check if a camera name is a debug replay camera.
|
||||
*
|
||||
* @param name - The camera name to check
|
||||
* @returns true if the camera is a replay camera
|
||||
*/
|
||||
export function isReplayCamera(name: string): boolean {
|
||||
return name.startsWith(REPLAY_CAMERA_PREFIX);
|
||||
}
|
||||
|
||||
@ -514,13 +514,18 @@ const mergeSectionConfig = (
|
||||
|
||||
export function getSectionConfig(
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
level: "global" | "camera" | "replay",
|
||||
): SectionConfig {
|
||||
const entry = sectionConfigs[sectionKey];
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const overrides = level === "global" ? entry.global : entry.camera;
|
||||
const overrides =
|
||||
level === "global"
|
||||
? entry.global
|
||||
: level === "replay"
|
||||
? entry.replay
|
||||
: entry.camera;
|
||||
return mergeSectionConfig(entry.base, overrides);
|
||||
}
|
||||
|
||||
53
web/src/utils/wsUtil.ts
Normal file
53
web/src/utils/wsUtil.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { WsFeedMessage } from "@/api/ws";
|
||||
|
||||
const EVENT_TOPICS = new Set([
|
||||
"events",
|
||||
"reviews",
|
||||
"tracked_object_update",
|
||||
"triggers",
|
||||
]);
|
||||
|
||||
const SYSTEM_TOPICS = new Set([
|
||||
"stats",
|
||||
"model_state",
|
||||
"job_state",
|
||||
"embeddings_reindex_progress",
|
||||
"audio_transcription_state",
|
||||
"birdseye_layout",
|
||||
]);
|
||||
|
||||
export function extractCameraName(message: WsFeedMessage): string | null {
|
||||
// Try extracting from topic pattern: {camera}/motion, {camera}/audio/rms, etc.
|
||||
const topicParts = message.topic.split("/");
|
||||
if (
|
||||
topicParts.length >= 2 &&
|
||||
!EVENT_TOPICS.has(message.topic) &&
|
||||
!SYSTEM_TOPICS.has(message.topic) &&
|
||||
message.topic !== "camera_activity" &&
|
||||
message.topic !== "audio_detections" &&
|
||||
message.topic !== "restart" &&
|
||||
message.topic !== "notification_test"
|
||||
) {
|
||||
return topicParts[0];
|
||||
}
|
||||
|
||||
// Try extracting from payload
|
||||
try {
|
||||
const data =
|
||||
typeof message.payload === "string"
|
||||
? JSON.parse(message.payload)
|
||||
: message.payload;
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
if ("camera" in data) return data.camera as string;
|
||||
if ("after" in data && data.after?.camera)
|
||||
return data.after.camera as string;
|
||||
if ("before" in data && data.before?.camera)
|
||||
return data.before.camera as string;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import ReviewCard from "@/components/card/ReviewCard";
|
||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||
import DebugReplayDialog from "@/components/overlay/DebugReplayDialog";
|
||||
import ExportDialog from "@/components/overlay/ExportDialog";
|
||||
import ActionsDropdown from "@/components/overlay/ActionsDropdown";
|
||||
import PreviewPlayer, {
|
||||
PreviewController,
|
||||
} from "@/components/player/PreviewPlayer";
|
||||
@ -199,6 +201,11 @@ export function RecordingView({
|
||||
const [exportRange, setExportRange] = useState<TimeRange>();
|
||||
const [showExportPreview, setShowExportPreview] = useState(false);
|
||||
|
||||
// debug replay
|
||||
|
||||
const [debugReplayMode, setDebugReplayMode] = useState<ExportMode>("none");
|
||||
const [debugReplayRange, setDebugReplayRange] = useState<TimeRange>();
|
||||
|
||||
// move to next clip
|
||||
|
||||
const onClipEnded = useCallback(() => {
|
||||
@ -269,7 +276,7 @@ export function RecordingView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrubbing || exportRange) {
|
||||
if (scrubbing || exportRange || debugReplayRange) {
|
||||
if (
|
||||
currentTime > currentTimeRange.before + 60 ||
|
||||
currentTime < currentTimeRange.after - 60
|
||||
@ -591,6 +598,23 @@ export function RecordingView({
|
||||
selected={mainCamera}
|
||||
onSelectCamera={onSelectCamera}
|
||||
/>
|
||||
{isDesktop && (
|
||||
<DebugReplayDialog
|
||||
camera={mainCamera}
|
||||
currentTime={currentTime}
|
||||
latestTime={timeRange.before}
|
||||
mode={debugReplayMode}
|
||||
range={debugReplayRange}
|
||||
setRange={(range: TimeRange | undefined) => {
|
||||
setDebugReplayRange(range);
|
||||
|
||||
if (range != undefined) {
|
||||
mainControllerRef.current?.pause();
|
||||
}
|
||||
}}
|
||||
setMode={setDebugReplayMode}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && (
|
||||
<ExportDialog
|
||||
camera={mainCamera}
|
||||
@ -639,6 +663,28 @@ export function RecordingView({
|
||||
setMotionOnly={() => {}}
|
||||
/>
|
||||
)}
|
||||
{isDesktop && (
|
||||
<ActionsDropdown
|
||||
onDebugReplayClick={() => {
|
||||
const now = new Date(timeRange.before * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
setDebugReplayRange({
|
||||
after: now.getTime() / 1000,
|
||||
before: timeRange.before,
|
||||
});
|
||||
setDebugReplayMode("select");
|
||||
}}
|
||||
onExportClick={() => {
|
||||
const now = new Date(timeRange.before * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
setExportRange({
|
||||
before: timeRange.before,
|
||||
after: now.getTime() / 1000,
|
||||
});
|
||||
setExportMode("select");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isDesktop ? (
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
@ -688,6 +734,16 @@ export function RecordingView({
|
||||
showExportPreview={showExportPreview}
|
||||
allLabels={reviewFilterList.labels}
|
||||
allZones={reviewFilterList.zones}
|
||||
debugReplayMode={debugReplayMode}
|
||||
debugReplayRange={debugReplayRange}
|
||||
setDebugReplayMode={setDebugReplayMode}
|
||||
setDebugReplayRange={(range: TimeRange | undefined) => {
|
||||
setDebugReplayRange(range);
|
||||
|
||||
if (range != undefined) {
|
||||
mainControllerRef.current?.pause();
|
||||
}
|
||||
}}
|
||||
onUpdateFilter={updateFilter}
|
||||
setRange={setExportRange}
|
||||
setMode={setExportMode}
|
||||
@ -758,7 +814,9 @@ export function RecordingView({
|
||||
timeRange={currentTimeRange}
|
||||
cameraPreviews={allPreviews ?? []}
|
||||
startTimestamp={playbackStart}
|
||||
hotKeys={exportMode != "select"}
|
||||
hotKeys={
|
||||
exportMode != "select" && debugReplayMode != "select"
|
||||
}
|
||||
fullscreen={fullscreen}
|
||||
onTimestampUpdate={(timestamp) => {
|
||||
setPlayerTime(timestamp);
|
||||
@ -772,7 +830,11 @@ export function RecordingView({
|
||||
onControllerReady={(controller) => {
|
||||
mainControllerRef.current = controller;
|
||||
}}
|
||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
||||
isScrubbing={
|
||||
scrubbing ||
|
||||
exportMode == "timeline" ||
|
||||
debugReplayMode == "timeline"
|
||||
}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
setFullResolution={setFullResolution}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
@ -840,18 +902,29 @@ export function RecordingView({
|
||||
contentRef={contentRef}
|
||||
mainCamera={mainCamera}
|
||||
timelineType={
|
||||
(exportRange == undefined ? timelineType : "timeline") ??
|
||||
"timeline"
|
||||
(exportRange == undefined && debugReplayRange == undefined
|
||||
? timelineType
|
||||
: "timeline") ?? "timeline"
|
||||
}
|
||||
timeRange={timeRange}
|
||||
mainCameraReviewItems={mainCameraReviewItems}
|
||||
activeReviewItem={activeReviewItem}
|
||||
currentTime={currentTime}
|
||||
exportRange={exportMode == "timeline" ? exportRange : undefined}
|
||||
exportRange={
|
||||
exportMode == "timeline"
|
||||
? exportRange
|
||||
: debugReplayMode == "timeline"
|
||||
? debugReplayRange
|
||||
: undefined
|
||||
}
|
||||
setCurrentTime={setCurrentTime}
|
||||
manuallySetCurrentTime={manuallySetCurrentTime}
|
||||
setScrubbing={setScrubbing}
|
||||
setExportRange={setExportRange}
|
||||
setExportRange={
|
||||
debugReplayMode == "timeline"
|
||||
? setDebugReplayRange
|
||||
: setExportRange
|
||||
}
|
||||
onAnalysisOpen={onAnalysisOpen}
|
||||
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user