mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-02 01:51:14 +03:00
Compare commits
21 Commits
9f631e6fa2
...
e0cbf50cc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0cbf50cc4 | ||
|
|
7a1b03e2c4 | ||
|
|
4cb1dccf59 | ||
|
|
c7f0e9497f | ||
|
|
5e6019b8e6 | ||
|
|
c62ba72fb0 | ||
|
|
6e90acc161 | ||
|
|
5ab324448d | ||
|
|
6b7a44d5a9 | ||
|
|
b9f816b062 | ||
|
|
18a7dc678b | ||
|
|
d6674e18d6 | ||
|
|
5d58471faf | ||
|
|
01d821347a | ||
|
|
984129535f | ||
|
|
2a90b5443e | ||
|
|
0d52439450 | ||
|
|
2ea1d7f3b0 | ||
|
|
605adb0677 | ||
|
|
28269a512d | ||
|
|
a0366baa9c |
@ -1083,6 +1083,22 @@ ui:
|
||||
# Optional: Set the time format used.
|
||||
# Options are browser, 12hour, or 24hour (default: shown below)
|
||||
time_format: browser
|
||||
# Optional: Set the date style for a specified length.
|
||||
# Options are: full, long, medium, short
|
||||
# Examples:
|
||||
# short: 2/11/23
|
||||
# medium: Feb 11, 2023
|
||||
# full: Saturday, February 11, 2023
|
||||
# (default: shown below).
|
||||
date_style: short
|
||||
# Optional: Set the time style for a specified length.
|
||||
# Options are: full, long, medium, short
|
||||
# Examples:
|
||||
# short: 8:14 PM
|
||||
# medium: 8:15:22 PM
|
||||
# full: 8:15:22 PM Mountain Standard Time
|
||||
# (default: shown below).
|
||||
time_style: medium
|
||||
# Optional: Set the unit system to either "imperial" or "metric" (default: metric)
|
||||
# Used in the UI and in MQTT topics
|
||||
unit_system: metric
|
||||
|
||||
@ -155,22 +155,21 @@ Motion Search lets you scan recorded footage for changes inside a region of inte
|
||||
|
||||
To start a search, open the Actions menu in History or click the kebab menu on a camera in the <NavPath path="Review > Motion" /> page and choose **Motion Search**. In the dialog:
|
||||
|
||||
1. Pick the camera and time range to scan. In the date pickers, days that have recordings available are underlined.
|
||||
1. Pick the camera and time range to scan.
|
||||
2. Draw a polygon on the camera frame to define the region of interest.
|
||||
3. Adjust the search parameters if needed:
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
|
||||
| **Minimum Change Area** | Minimum size of a single moving region, as a percentage of the ROI, for a frame to count as significant. Raise it to ignore small movements (leaves, distant motion); lower it when your subject covers only a small slice of the ROI. Every result shows the percentage it scored, so you can use those values to tune this. |
|
||||
| **Maximum Results** | Maximum number of matching timestamps to return. The search stops once it reaches this many results, so a lower value finishes sooner while a higher value scans further into the range. |
|
||||
| **Parallel mode** | Decode multiple recording ranges at the same time. Speeds up large time ranges at the cost of higher decoding and CPU usage. |
|
||||
|
||||
Motion Search samples each recording's keyframes automatically, so there is no frame-rate or sampling setting to tune.
|
||||
| Field | Description |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Sensitivity Threshold** | Per-pixel luminance change required to count as motion inside the ROI. Behaves like Frigate's motion detection `threshold` setting. |
|
||||
| **Minimum Change Area** | Minimum percentage of the region of interest that must change for a frame to be considered significant. Raise it to ignore small movements (leaves, distant motion); lower it when the object you care about only covers a small slice of the ROI. |
|
||||
| **Frame Skip** | Number of frames to skip between samples — at a camera recording 20 fps, a skip value of 20 takes motion samples roughly once per second. Higher values scan much faster and are usually the right choice; lower it only when you need to catch the exact appearance or disappearance of a fast-moving object. |
|
||||
| **Maximum Results** | Maximum number of matching timestamps to return. |
|
||||
| **Parallel mode** | Process multiple recording segments in parallel. Speeds up large time ranges at the cost of higher CPU usage. |
|
||||
|
||||
Once running, Frigate scans the recording segments that overlap the time range and reports timestamps where changes were detected inside the polygon, along with the percentage of the ROI that changed. Clicking a result seeks the player to that moment so you can review what happened.
|
||||
|
||||
The results panel shows the time range being scanned, a live progress bar with the timestamp currently being analyzed, and the running result count. A collapsible **Search Metrics** section reports how many segments were scanned and processed, how many were skipped because no motion was recorded in the ROI (using the stored motion heatmap), how many frames were decoded, and the total search time. Skipping segments with no recorded motion in the selected ROI is what makes searching long time ranges practical.
|
||||
The status panel shows live progress and metrics such as how many segments were scanned, how many were skipped because no motion was recorded for that segment (using the stored motion heatmap), how many frames were decoded, and the total wall-clock time. Segments with no recorded motion in the selected ROI are skipped automatically, which is what makes searching long time ranges practical.
|
||||
|
||||
#### Common use cases
|
||||
|
||||
@ -180,15 +179,6 @@ Frigate's main use case is to record and surface tracked objects, so Motion Sear
|
||||
- **An object that was never detected.** Something Frigate doesn't have a model label for, an object too small or distant to be detected, or movement in a region where detection isn't running. The activity left no tracked object but did change the pixels, so a search can still find it.
|
||||
- **Activity while detection was effectively paused.** Changes that occurred while object detection was disabled, motion was suppressed by `skip_motion_threshold`, or inside an area covered by a motion mask, won't appear as review items or tracked objects but can be recovered by searching the recordings directly.
|
||||
|
||||
#### Examples
|
||||
|
||||
These show how to choose the ROI and **Minimum Change Area** for two common goals. Minimum Change Area is the size of a single moving region as a percentage of the ROI you draw, so the right value depends on how much of the ROI your subject — and its movement between samples — covers.
|
||||
|
||||
Because samples are a second or more apart, a moving subject usually appears in two places at once in the comparison, so even ordinary motion often scores tens of percent and a low threshold lets in almost everything. The most reliable approach is to **run a search, look at the percentage each result scored, and set Minimum Change Area just below the values for the events you care about.** The default is 20%; the suggestions below are starting points.
|
||||
|
||||
- **When did this item first appear (or disappear)?** A package was dropped off, a car parked, or a trash can was moved, and you want the exact moment. Draw a **tight ROI** around the spot the item occupies and **raise Minimum Change Area** (start around 40–60%). Because the item fills most of a tight ROI, its arrival or removal is a large change, while smaller nearby motion (shadows, a passing pedestrian) stays below the threshold. The **earliest result** is when it appeared; if you only care about that moment, a low Maximum Results finishes faster. If you get no hits, the ROI is probably looser than the item — lower the threshold or tighten the ROI.
|
||||
- **What's been getting into the garden?** Something has been trampling a flower bed overnight and no object was ever tracked. Draw a **looser ROI** covering the whole bed and use a **lower Minimum Change Area than the case above** — start near the 20% default and lower it (toward 5–10%) only if a small or distant subject is missed, since it covers just a slice of a large region. Expect more results to scan through — step through the timestamps and jump to each to see what triggered it. If wind-blown plants add noise, raise Minimum Change Area or the Sensitivity Threshold.
|
||||
|
||||
#### Expected performance
|
||||
|
||||
Motion Search analyzes the saved recordings on demand rather than reading a pre-built index, so a search over a long range takes longer than browsing Motion Previews. Cost scales mainly with how much footage has to be examined: segments with no recorded motion in your ROI are skipped using the stored motion heatmap (shown as "segments skipped" in the status panel), so a quiet range finishes quickly while a busy one takes longer.
|
||||
@ -196,6 +186,5 @@ Motion Search analyzes the saved recordings on demand rather than reading a pre-
|
||||
To increase the speed of searches:
|
||||
|
||||
- Draw a tight ROI. Because **Minimum Change Area** is measured as a percentage of the region you draw, a tight ROI around where you expect the change makes the object fill a larger share of the area, so it clears the threshold more easily. A loose ROI makes the same object a small fraction of the region, so it can fall below the threshold and be missed — forcing you to lower Minimum Change Area, which lets in more noise.
|
||||
- Narrow the time range to the window you care about, so there is less footage to examine.
|
||||
- Lower **Maximum Results** when you only need the first few hits. Because the search stops once it reaches that many results, a smaller value lets a busy range finish early instead of scanning the whole window.
|
||||
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher decoding and CPU usage while it runs.
|
||||
- Keep Frame Skip high. A higher value samples fewer frames and speeds up the search considerably, while still landing within a few seconds of when the motion or object appeared — close enough to seek to in the recording. Only lower it when you need to pinpoint the exact frame something appears or disappears.
|
||||
- Use Parallel mode to shorten wall-clock time on multi-core systems, at the cost of higher CPU usage while it runs.
|
||||
|
||||
17
docs/static/frigate-api.yaml
vendored
17
docs/static/frigate-api.yaml
vendored
@ -7288,6 +7288,13 @@ components:
|
||||
title: Min Area
|
||||
description: Minimum change area as a percentage of the ROI
|
||||
default: 5
|
||||
frame_skip:
|
||||
type: integer
|
||||
maximum: 30
|
||||
minimum: 1
|
||||
title: Frame Skip
|
||||
description: "Process every Nth frame (1=all frames, 5=every 5th frame)"
|
||||
default: 5
|
||||
parallel:
|
||||
type: boolean
|
||||
title: Parallel
|
||||
@ -7373,16 +7380,6 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/MotionSearchMetricsResponse"
|
||||
- type: "null"
|
||||
scanning_timestamp:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
title: Scanning Timestamp
|
||||
progress:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
title: Progress
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
|
||||
@ -41,6 +41,12 @@ class MotionSearchRequest(BaseModel):
|
||||
le=100.0,
|
||||
description="Minimum change area as a percentage of the ROI",
|
||||
)
|
||||
frame_skip: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=120,
|
||||
description="Process every Nth frame (1=all frames, 5=every 5th frame)",
|
||||
)
|
||||
parallel: bool = Field(
|
||||
default=False,
|
||||
description="Enable parallel scanning across segments",
|
||||
@ -91,8 +97,6 @@ class MotionSearchStatusResponse(BaseModel):
|
||||
total_frames_processed: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
metrics: Optional[MotionSearchMetricsResponse] = None
|
||||
scanning_timestamp: Optional[float] = None
|
||||
progress: Optional[float] = None
|
||||
|
||||
|
||||
@router.post(
|
||||
@ -147,6 +151,7 @@ async def start_motion_search(
|
||||
polygon_points=body.polygon_points,
|
||||
threshold=body.threshold,
|
||||
min_area=body.min_area,
|
||||
frame_skip=body.frame_skip,
|
||||
parallel=body.parallel,
|
||||
max_results=body.max_results,
|
||||
)
|
||||
@ -226,9 +231,6 @@ async def get_motion_search_status_endpoint(
|
||||
if job.metrics:
|
||||
response_content["metrics"] = job.metrics.to_dict()
|
||||
|
||||
response_content["scanning_timestamp"] = job.scanning_timestamp
|
||||
response_content["progress"] = job.progress
|
||||
|
||||
return JSONResponse(content=response_content)
|
||||
|
||||
|
||||
|
||||
@ -299,36 +299,22 @@ async def no_recordings(
|
||||
.iterator()
|
||||
)
|
||||
|
||||
# Convert recordings to list of (start, end) tuples, ordered by start_time
|
||||
# Convert recordings to list of (start, end) tuples
|
||||
recordings = [(r["start_time"], r["end_time"]) for r in data]
|
||||
|
||||
# Merge overlapping/adjacent recordings into covered intervals. The query
|
||||
# orders by start_time, so a single pass merges them
|
||||
covered: list[tuple[float, float]] = []
|
||||
for rec_start, rec_end in recordings:
|
||||
if covered and rec_start <= covered[-1][1]:
|
||||
covered[-1] = (covered[-1][0], max(covered[-1][1], rec_end))
|
||||
else:
|
||||
covered.append((rec_start, rec_end))
|
||||
|
||||
# Iterate through time segments and check if each has any recording
|
||||
no_recording_segments = []
|
||||
current = after
|
||||
current_gap_start = None
|
||||
idx = 0
|
||||
covered_count = len(covered)
|
||||
|
||||
while current < before:
|
||||
segment_end = min(current + scale, before)
|
||||
|
||||
# Advance past covered intervals that end before this segment begins;
|
||||
# they cannot overlap this or any later segment.
|
||||
while idx < covered_count and covered[idx][1] <= current:
|
||||
idx += 1
|
||||
|
||||
# A covered interval overlaps the segment when it starts before the
|
||||
# segment ends (its end is already known to be > current).
|
||||
has_recording = idx < covered_count and covered[idx][0] < segment_end
|
||||
# Check if this segment overlaps with any recording
|
||||
has_recording = any(
|
||||
rec_start < segment_end and rec_end > current
|
||||
for rec_start, rec_end in recordings
|
||||
)
|
||||
|
||||
if not has_recording:
|
||||
# This segment has no recordings
|
||||
|
||||
@ -605,10 +605,9 @@ def motion_activity(
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
camera_list = list(filtered)
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = list(allowed_cameras)
|
||||
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
clauses.append((Recordings.camera << allowed_cameras))
|
||||
|
||||
data: list[Recordings] = (
|
||||
Recordings.select(
|
||||
@ -636,12 +635,14 @@ def motion_activity(
|
||||
df.set_index(["start_time"], inplace=True)
|
||||
|
||||
# normalize data
|
||||
motion = df["motion"].resample(f"{scale}s").max().fillna(0.0).to_frame()
|
||||
|
||||
if len(camera_list) == 1:
|
||||
cameras = df["camera"].resample(f"{scale}s").first().fillna("")
|
||||
else:
|
||||
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
|
||||
motion = (
|
||||
df["motion"]
|
||||
.resample(f"{scale}s")
|
||||
.apply(lambda x: max(x, key=abs, default=0.0))
|
||||
.fillna(0.0)
|
||||
.to_frame()
|
||||
)
|
||||
cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x)))
|
||||
df = motion.join(cameras)
|
||||
|
||||
length = df.shape[0]
|
||||
@ -657,11 +658,6 @@ def motion_activity(
|
||||
else:
|
||||
df.iloc[i : i + chunk, 0] = 0.0
|
||||
|
||||
# Drop resample gap-fill buckets. The resample above emits a row for every
|
||||
# {scale}s bucket spanning the range, and buckets with no recording get a
|
||||
# motion of 0 (from fillna) and an empty camera (from joining an empty set).
|
||||
df = df[df["camera"] != ""]
|
||||
|
||||
# change types for output
|
||||
df.index = df.index.astype(int) // (10**9)
|
||||
normalized = df.reset_index().to_dict("records")
|
||||
|
||||
@ -146,7 +146,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
timestamp_style: TimestampStyleConfig = Field(
|
||||
default_factory=TimestampStyleConfig,
|
||||
title="Timestamp style",
|
||||
description="Styling options for timestamps applied to snapshots and Debug view.",
|
||||
description="Styling options for in-feed timestamps applied to recordings and snapshots.",
|
||||
)
|
||||
|
||||
# Options without global fallback
|
||||
|
||||
@ -5,7 +5,7 @@ import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Optional
|
||||
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
@ -34,45 +34,6 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
|
||||
"zones": CameraConfigUpdateEnum.zones,
|
||||
}
|
||||
|
||||
# Retained MQTT switch topics per profile section, with a payload getter.
|
||||
# Republished on profile change so MQTT/HA don't show a stale toggle.
|
||||
SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
|
||||
"audio": [("audio", lambda c: "ON" if c.audio.enabled else "OFF")],
|
||||
"birdseye": [
|
||||
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
||||
(
|
||||
"birdseye_mode",
|
||||
lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF",
|
||||
),
|
||||
],
|
||||
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
|
||||
"motion": [
|
||||
("motion", lambda c: "ON" if c.motion.enabled else "OFF"),
|
||||
("improve_contrast", lambda c: "ON" if c.motion.improve_contrast else "OFF"),
|
||||
("motion_threshold", lambda c: c.motion.threshold),
|
||||
("motion_contour_area", lambda c: c.motion.contour_area),
|
||||
],
|
||||
"notifications": [
|
||||
("notifications", lambda c: "ON" if c.notifications.enabled else "OFF"),
|
||||
],
|
||||
"objects": [
|
||||
("object_descriptions", lambda c: "ON" if c.objects.genai.enabled else "OFF"),
|
||||
],
|
||||
"record": [("recordings", lambda c: "ON" if c.record.enabled else "OFF")],
|
||||
"review": [
|
||||
("review_alerts", lambda c: "ON" if c.review.alerts.enabled else "OFF"),
|
||||
(
|
||||
"review_detections",
|
||||
lambda c: "ON" if c.review.detections.enabled else "OFF",
|
||||
),
|
||||
(
|
||||
"review_descriptions",
|
||||
lambda c: "ON" if c.review.genai.enabled else "OFF",
|
||||
),
|
||||
],
|
||||
"snapshots": [("snapshots", lambda c: "ON" if c.snapshots.enabled else "OFF")],
|
||||
}
|
||||
|
||||
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
|
||||
|
||||
|
||||
@ -349,15 +310,6 @@ class ProfileManager:
|
||||
settings,
|
||||
)
|
||||
|
||||
# republish MQTT switch states
|
||||
if self.dispatcher is not None:
|
||||
for suffix, get_payload in SECTION_STATE_TOPICS.get(section, ()):
|
||||
self.dispatcher.publish(
|
||||
f"{cam_name}/{suffix}/state",
|
||||
get_payload(cam_config),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
def _persist_active_profile(self, profile_name: Optional[str]) -> None:
|
||||
"""Persist the active profile state to disk as JSON."""
|
||||
try:
|
||||
|
||||
@ -45,7 +45,7 @@ class ProxyConfig(FrigateBaseModel):
|
||||
default_role: Optional[str] = Field(
|
||||
default="viewer",
|
||||
title="Default role",
|
||||
description="Default role assigned to proxy-authenticated users when no role mapping applies.",
|
||||
description="Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer).",
|
||||
)
|
||||
separator: Optional[str] = Field(
|
||||
default=",",
|
||||
|
||||
@ -5,7 +5,7 @@ from pydantic import Field
|
||||
|
||||
from .base import FrigateBaseModel
|
||||
|
||||
__all__ = ["TimeFormatEnum", "UnitSystemEnum", "UIConfig"]
|
||||
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"]
|
||||
|
||||
|
||||
class TimeFormatEnum(str, Enum):
|
||||
@ -14,6 +14,13 @@ class TimeFormatEnum(str, Enum):
|
||||
hours24 = "24hour"
|
||||
|
||||
|
||||
class DateTimeStyleEnum(str, Enum):
|
||||
full = "full"
|
||||
long = "long"
|
||||
medium = "medium"
|
||||
short = "short"
|
||||
|
||||
|
||||
class UnitSystemEnum(str, Enum):
|
||||
imperial = "imperial"
|
||||
metric = "metric"
|
||||
@ -30,6 +37,16 @@ class UIConfig(FrigateBaseModel):
|
||||
title="Time format",
|
||||
description="Time format to use in the UI (browser, 12hour, or 24hour).",
|
||||
)
|
||||
date_style: DateTimeStyleEnum = Field(
|
||||
default=DateTimeStyleEnum.short,
|
||||
title="Date style",
|
||||
description="Date style to use in the UI (full, long, medium, short).",
|
||||
)
|
||||
time_style: DateTimeStyleEnum = Field(
|
||||
default=DateTimeStyleEnum.medium,
|
||||
title="Time style",
|
||||
description="Time style to use in the UI (full, long, medium, short).",
|
||||
)
|
||||
unit_system: UnitSystemEnum = Field(
|
||||
default=UnitSystemEnum.metric,
|
||||
title="Unit system",
|
||||
|
||||
@ -3,8 +3,6 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Generator, Iterable
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
@ -21,18 +19,6 @@ from frigate.jobs.manager import (
|
||||
get_job_by_id,
|
||||
set_current_job,
|
||||
)
|
||||
from frigate.jobs.motion_search_batch import (
|
||||
build_segment_time_map,
|
||||
coalesce_runs,
|
||||
stream_time_to_absolute,
|
||||
)
|
||||
from frigate.jobs.motion_search_decode import (
|
||||
iter_vod_frames,
|
||||
keyframe_sampling_eligible,
|
||||
probe_video_dimensions,
|
||||
probe_vod_keyframe_pts,
|
||||
resolve_motion_decode_args,
|
||||
)
|
||||
from frigate.models import Recordings
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
@ -40,18 +26,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
HEATMAP_GRID_SIZE = 16
|
||||
# Max wall-clock span of one VOD run request (seconds). Bounds per-request size
|
||||
# and gives streaming/cancel/early-exit granularity.
|
||||
MAX_RUN_SECONDS = 600.0
|
||||
# Treat segments within this many seconds end-to-start as time-contiguous.
|
||||
RUN_GAP_EPSILON = 1.0
|
||||
# Longest-side pixels for the ROI downscale before motion detection.
|
||||
SCALE_TARGET = 400
|
||||
# Minimum wall seconds between intra-run progress broadcasts.
|
||||
PROGRESS_BROADCAST_INTERVAL = 1.0
|
||||
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
|
||||
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
|
||||
FALLBACK_SAMPLE_FPS = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -95,16 +69,13 @@ class MotionSearchJob(Job):
|
||||
polygon_points: list[list[float]] = field(default_factory=list)
|
||||
threshold: int = 30
|
||||
min_area: float = 5.0
|
||||
frame_skip: int = 5
|
||||
parallel: bool = False
|
||||
max_results: int = 25
|
||||
|
||||
# Track progress
|
||||
total_frames_processed: int = 0
|
||||
|
||||
# Live progress (ride the existing to_dict() websocket broadcast)
|
||||
scanning_timestamp: Optional[float] = None
|
||||
progress: float = 0.0
|
||||
|
||||
# Metrics for observability
|
||||
metrics: Optional[MotionSearchMetrics] = None
|
||||
|
||||
@ -129,113 +100,6 @@ def create_polygon_mask(
|
||||
return mask
|
||||
|
||||
|
||||
def compute_roi_crop_and_scale(
|
||||
polygon_points: list[list[float]],
|
||||
frame_width: int,
|
||||
frame_height: int,
|
||||
scale_target: int,
|
||||
) -> tuple[tuple[int, int, int, int], tuple[int, int]]:
|
||||
"""Compute the ROI crop box and never-upscale scaled dimensions.
|
||||
|
||||
Returns ((crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)) in pixels.
|
||||
The crop is the polygon's bounding box in frame pixels; the scaled size fits
|
||||
the crop's longest side to ``scale_target`` without ever enlarging it.
|
||||
"""
|
||||
xs = [p[0] for p in polygon_points]
|
||||
ys = [p[1] for p in polygon_points]
|
||||
# nv12 (4:2:0) hwdownload requires even crop offsets and even crop/scale
|
||||
# dimensions; otherwise ffmpeg rounds the chroma planes and the raw byte
|
||||
# stream stops matching the expected frame size. Force even values, and the
|
||||
# mask is built from these same values so the two stay aligned.
|
||||
crop_x = int(min(xs) * frame_width)
|
||||
crop_y = int(min(ys) * frame_height)
|
||||
crop_x -= crop_x % 2
|
||||
crop_y -= crop_y % 2
|
||||
crop_w = max(2, int(max(xs) * frame_width) - crop_x)
|
||||
crop_h = max(2, int(max(ys) * frame_height) - crop_y)
|
||||
crop_w -= crop_w % 2
|
||||
crop_h -= crop_h % 2
|
||||
|
||||
longest = max(crop_w, crop_h)
|
||||
factor = min(1.0, scale_target / longest)
|
||||
scaled_w = max(2, round(crop_w * factor))
|
||||
scaled_h = max(2, round(crop_h * factor))
|
||||
scaled_w -= scaled_w % 2
|
||||
scaled_h -= scaled_h % 2
|
||||
return (crop_w, crop_h, crop_x, crop_y), (scaled_w, scaled_h)
|
||||
|
||||
|
||||
def build_scaled_roi_mask(
|
||||
polygon_points: list[list[float]],
|
||||
frame_width: int,
|
||||
frame_height: int,
|
||||
crop: tuple[int, int, int, int],
|
||||
scaled: tuple[int, int],
|
||||
) -> np.ndarray:
|
||||
"""Rasterize the polygon mask at the scaled ROI size.
|
||||
|
||||
Builds the full-resolution mask, crops it to the ROI box, and nearest-
|
||||
neighbor resizes it to the scaled dimensions so it lines up exactly with the
|
||||
frames ffmpeg crops and scales.
|
||||
"""
|
||||
crop_w, crop_h, crop_x, crop_y = crop
|
||||
scaled_w, scaled_h = scaled
|
||||
full_mask = create_polygon_mask(polygon_points, frame_width, frame_height)
|
||||
cropped = full_mask[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w]
|
||||
return cv2.resize(cropped, (scaled_w, scaled_h), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
|
||||
def detect_motion_scaled(
|
||||
frames: Iterable[tuple[int, np.ndarray]],
|
||||
mask: np.ndarray,
|
||||
threshold: int,
|
||||
min_area: float,
|
||||
timestamp_fn: Callable[[int], float],
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Detect motion across pre-cropped, pre-scaled gray frames.
|
||||
|
||||
``frames`` yields (absolute_frame_index, gray_roi_frame); ``mask`` is the
|
||||
scaled ROI mask. ``min_area`` is a percentage of the masked ROI. Mirrors the
|
||||
full-res detection math (absdiff -> blur -> threshold -> dilate -> contours)
|
||||
on the already-reduced frames.
|
||||
"""
|
||||
results: list[MotionSearchResult] = []
|
||||
mask_area = np.count_nonzero(mask)
|
||||
if mask_area == 0:
|
||||
return results
|
||||
min_area_pixels = int((min_area / 100.0) * mask_area)
|
||||
|
||||
prev: np.ndarray | None = None
|
||||
for frame_idx, gray in frames:
|
||||
masked = cv2.bitwise_and(gray, gray, mask=mask)
|
||||
if prev is not None:
|
||||
diff = cv2.absdiff(prev, masked)
|
||||
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
|
||||
_, thresh = cv2.threshold(diff_blurred, threshold, 255, cv2.THRESH_BINARY)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload]
|
||||
thresh_masked = cv2.bitwise_and(thresh_dilated, thresh_dilated, mask=mask)
|
||||
change_pixels = cv2.countNonZero(thresh_masked)
|
||||
if change_pixels > min_area_pixels:
|
||||
contours, _ = cv2.findContours(
|
||||
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
total_change_area = sum(
|
||||
cv2.contourArea(c)
|
||||
for c in contours
|
||||
if cv2.contourArea(c) >= min_area_pixels
|
||||
)
|
||||
if total_change_area > 0:
|
||||
change_percentage = (total_change_area / mask_area) * 100
|
||||
results.append(
|
||||
MotionSearchResult(
|
||||
timestamp=timestamp_fn(frame_idx),
|
||||
change_percentage=round(change_percentage, 2),
|
||||
)
|
||||
)
|
||||
prev = masked
|
||||
return results
|
||||
|
||||
|
||||
def compute_roi_bbox_normalized(
|
||||
polygon_points: list[list[float]],
|
||||
) -> tuple[float, float, float, float]:
|
||||
@ -320,22 +184,6 @@ def segment_passes_heatmap_gate(
|
||||
return heatmap_overlaps_roi(heatmap, roi_bbox)
|
||||
|
||||
|
||||
def resolve_internal_port(config: FrigateConfig) -> int:
|
||||
"""Return the unauthenticated internal nginx port for VOD requests."""
|
||||
listen = config.networking.listen.internal
|
||||
if isinstance(listen, str):
|
||||
return int(listen.split(":")[-1])
|
||||
return int(listen)
|
||||
|
||||
|
||||
def build_vod_url(internal_port: int, camera: str, start: float, end: float) -> str:
|
||||
"""Build the internal VOD HLS URL for a camera time range."""
|
||||
return (
|
||||
f"http://127.0.0.1:{internal_port}/vod/{camera}"
|
||||
f"/start/{start}/end/{end}/index.m3u8"
|
||||
)
|
||||
|
||||
|
||||
class MotionSearchRunner(threading.Thread):
|
||||
"""Thread-based runner for motion search jobs with parallel verification."""
|
||||
|
||||
@ -358,23 +206,6 @@ class MotionSearchRunner(threading.Thread):
|
||||
cpu_count = os.cpu_count() or 1
|
||||
self.max_workers = min(4, cpu_count)
|
||||
|
||||
# Resolved once per job in _execute_search
|
||||
self.ffmpeg_path: str = "ffmpeg"
|
||||
self.ffprobe_path: str = "ffprobe"
|
||||
self.decode_args: list[str] = []
|
||||
# Keyframe sampling decision, decided once per job from the first run's
|
||||
# GOP. The fallback cadence is a fixed rate (see FALLBACK_SAMPLE_FPS).
|
||||
self.use_keyframe: bool = True
|
||||
self.fps_rate: float = FALLBACK_SAMPLE_FPS
|
||||
# ROI crop/scale + scaled mask, computed once from the VOD-stream
|
||||
# dimensions (which can differ from the detect resolution).
|
||||
self.crop: tuple[int, int, int, int] = (0, 0, 0, 0)
|
||||
self.scaled: tuple[int, int] = (0, 0)
|
||||
self.scaled_mask: np.ndarray = np.zeros((0, 0), dtype=np.uint8)
|
||||
self.channels: int = 1
|
||||
self.internal_port: int = 5000
|
||||
self._last_progress_broadcast: float = 0.0
|
||||
|
||||
def run(self) -> None:
|
||||
"""Execute the motion search job."""
|
||||
try:
|
||||
@ -450,9 +281,6 @@ class MotionSearchRunner(threading.Thread):
|
||||
if frame_width is None or frame_height is None:
|
||||
raise ValueError(f"Camera {camera_name} detect dimensions not configured")
|
||||
|
||||
self.ffmpeg_path = camera_config.ffmpeg.ffmpeg_path
|
||||
self.ffprobe_path = camera_config.ffmpeg.ffprobe_path
|
||||
|
||||
# Create polygon mask
|
||||
polygon_mask = create_polygon_mask(
|
||||
self.job.polygon_points, frame_width, frame_height
|
||||
@ -556,274 +384,205 @@ class MotionSearchRunner(threading.Thread):
|
||||
self.metrics.heatmap_roi_skip_segments,
|
||||
)
|
||||
|
||||
# Resolve decode backend (allowlisted hwaccel or software), coalesce the
|
||||
# gate-passing segments into time-contiguous runs, and probe the first
|
||||
# run's VOD stream once for dimensions + keyframe layout. VOD output is
|
||||
# what we decode, so crop/scale/mask are computed against it.
|
||||
self.internal_port = resolve_internal_port(self.config)
|
||||
self.decode_args = resolve_motion_decode_args(camera_config)
|
||||
ffprobe_path = self.ffprobe_path
|
||||
if self.job.parallel:
|
||||
return self._search_motion_parallel(filtered_recordings, polygon_mask)
|
||||
|
||||
runs = coalesce_runs(filtered_recordings, MAX_RUN_SECONDS, RUN_GAP_EPSILON)
|
||||
if not runs:
|
||||
return []
|
||||
return self._search_motion_sequential(filtered_recordings, polygon_mask)
|
||||
|
||||
first_run = runs[0]
|
||||
first_url = build_vod_url(
|
||||
self.internal_port,
|
||||
camera_name,
|
||||
float(first_run[0].start_time),
|
||||
float(first_run[-1].end_time),
|
||||
)
|
||||
dims = probe_video_dimensions(ffprobe_path, first_url)
|
||||
if dims is None:
|
||||
raise ValueError(f"Could not probe VOD dimensions for camera {camera_name}")
|
||||
rec_width, rec_height, _rec_fps = dims
|
||||
|
||||
self.crop, self.scaled = compute_roi_crop_and_scale(
|
||||
self.job.polygon_points, rec_width, rec_height, SCALE_TARGET
|
||||
)
|
||||
self.scaled_mask = build_scaled_roi_mask(
|
||||
self.job.polygon_points, rec_width, rec_height, self.crop, self.scaled
|
||||
)
|
||||
self.channels = 1 # always gray output
|
||||
|
||||
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
|
||||
# (keyframe structure is a per-camera constant).
|
||||
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url)
|
||||
self.use_keyframe = keyframe_sampling_eligible(first_pts)
|
||||
def _search_motion_parallel(
|
||||
self,
|
||||
recordings: list[Recordings],
|
||||
polygon_mask: np.ndarray,
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Search for motion in parallel across segments, streaming results."""
|
||||
all_results: list[MotionSearchResult] = []
|
||||
total_frames = 0
|
||||
next_recording_idx_to_merge = 0
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: %d runs, sampling=%s, hwaccel=%s, vod=%dx%d",
|
||||
"Motion search job %s: starting motion search with %d workers "
|
||||
"across %d segments",
|
||||
self.job.id,
|
||||
len(runs),
|
||||
"keyframe" if self.use_keyframe else "cadence",
|
||||
bool(self.decode_args),
|
||||
rec_width,
|
||||
rec_height,
|
||||
self.max_workers,
|
||||
len(recordings),
|
||||
)
|
||||
|
||||
return self._search_runs(runs)
|
||||
|
||||
def _emit_progress(self, abs_ts: float) -> None:
|
||||
"""Throttled intra-run progress broadcast (scanning cursor)."""
|
||||
now = time.monotonic()
|
||||
if now - self._last_progress_broadcast < PROGRESS_BROADCAST_INTERVAL:
|
||||
return
|
||||
self._last_progress_broadcast = now
|
||||
self.job.scanning_timestamp = abs_ts
|
||||
self._broadcast_status()
|
||||
|
||||
def _detect_with_progress(
|
||||
self,
|
||||
indexed_frames: list[tuple[int, np.ndarray]],
|
||||
timestamp_fn: Callable[[int], float],
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Run detection while firing throttled progress as frames are scanned."""
|
||||
|
||||
def _gen() -> Generator[tuple[int, np.ndarray], None, None]:
|
||||
for i, frame in indexed_frames:
|
||||
if not self._should_stop():
|
||||
self._emit_progress(timestamp_fn(i))
|
||||
yield i, frame
|
||||
|
||||
return detect_motion_scaled(
|
||||
_gen(),
|
||||
self.scaled_mask,
|
||||
self.job.threshold,
|
||||
self.job.min_area,
|
||||
timestamp_fn,
|
||||
)
|
||||
|
||||
def _process_run(
|
||||
self, run: list[Recordings]
|
||||
) -> tuple[list[MotionSearchResult], int]:
|
||||
"""Decode one run's VOD stream and detect motion.
|
||||
|
||||
Keyframe mode compares every decoded keyframe (free recall, since they
|
||||
are all decoded anyway) paired with its probed PTS; if the decoded and
|
||||
probed counts disagree (the decoder ignored ``-skip_frame nokey`` or the
|
||||
stream is corrupt) this run re-runs in the fixed-cadence fallback.
|
||||
Returns ``(results, frame_count)``.
|
||||
"""
|
||||
run_start: float = run[0].start_time # type: ignore[assignment]
|
||||
run_end: float = run[-1].end_time # type: ignore[assignment]
|
||||
vod_url = build_vod_url(self.internal_port, self.job.camera, run_start, run_end)
|
||||
time_map = build_segment_time_map(run)
|
||||
|
||||
if self.use_keyframe:
|
||||
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url)
|
||||
frames = list(
|
||||
iter_vod_frames(
|
||||
self.ffmpeg_path,
|
||||
vod_url,
|
||||
self.scaled[0],
|
||||
self.scaled[1],
|
||||
self.channels,
|
||||
self.decode_args,
|
||||
self.crop,
|
||||
self.scaled,
|
||||
True,
|
||||
self._should_stop,
|
||||
skip_nonkey=True,
|
||||
fps_rate=None,
|
||||
)
|
||||
)
|
||||
if kf_pts and len(frames) == len(kf_pts):
|
||||
abs_times = [stream_time_to_absolute(time_map, p) for p in kf_pts]
|
||||
indexed = list(enumerate(frames))
|
||||
|
||||
def _ts_kf(i: int) -> float:
|
||||
return abs_times[i]
|
||||
|
||||
results = self._detect_with_progress(indexed, _ts_kf)
|
||||
return results, len(frames)
|
||||
|
||||
logger.debug(
|
||||
"Keyframe count mismatch (%d decoded vs %d probed), using cadence",
|
||||
len(frames),
|
||||
len(kf_pts),
|
||||
)
|
||||
|
||||
return self._process_run_cadence(vod_url, time_map)
|
||||
|
||||
def _process_run_cadence(
|
||||
self, vod_url: str, time_map: list[tuple[float, float, float]]
|
||||
) -> tuple[list[MotionSearchResult], int]:
|
||||
"""Fixed-cadence fallback: fps-filtered VOD decode, evenly spaced times."""
|
||||
frames = list(
|
||||
iter_vod_frames(
|
||||
self.ffmpeg_path,
|
||||
vod_url,
|
||||
self.scaled[0],
|
||||
self.scaled[1],
|
||||
self.channels,
|
||||
self.decode_args,
|
||||
self.crop,
|
||||
self.scaled,
|
||||
True,
|
||||
self._should_stop,
|
||||
skip_nonkey=False,
|
||||
fps_rate=self.fps_rate,
|
||||
)
|
||||
)
|
||||
indexed = list(enumerate(frames))
|
||||
|
||||
def _ts_fps(i: int) -> float:
|
||||
return stream_time_to_absolute(time_map, i / self.fps_rate)
|
||||
|
||||
results = self._detect_with_progress(indexed, _ts_fps)
|
||||
return results, len(frames)
|
||||
|
||||
def _merge_run(
|
||||
self,
|
||||
run: list[Recordings],
|
||||
run_results: list[MotionSearchResult],
|
||||
frames: int,
|
||||
state: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Fold one run's output into the running results; stream + dedup.
|
||||
|
||||
Returns True once ``max_results`` deduped hits have accumulated.
|
||||
"""
|
||||
state["completed_runs"] += 1
|
||||
state["all_results"].extend(run_results)
|
||||
state["total_frames"] += frames
|
||||
self.job.total_frames_processed = state["total_frames"]
|
||||
self.metrics.frames_decoded = state["total_frames"]
|
||||
self.metrics.segments_processed += len(run)
|
||||
self.job.progress = state["completed_runs"] / state["total_runs"]
|
||||
|
||||
state["all_results"].sort(key=lambda r: r.timestamp)
|
||||
deduped = self._deduplicate_results(state["all_results"])[
|
||||
: self.job.max_results
|
||||
]
|
||||
self.job.results = {
|
||||
"results": [r.to_dict() for r in deduped],
|
||||
"total_frames_processed": state["total_frames"],
|
||||
}
|
||||
self._broadcast_status()
|
||||
return len(deduped) >= self.job.max_results
|
||||
|
||||
def _search_runs(self, runs: list[list[Recordings]]) -> list[MotionSearchResult]:
|
||||
"""Decode runs (parallel pool when enabled), merge in order, stream."""
|
||||
state: dict[str, Any] = {
|
||||
"all_results": [],
|
||||
"total_frames": 0,
|
||||
"completed_runs": 0,
|
||||
"total_runs": len(runs),
|
||||
}
|
||||
# Initialize partial results on the job so they stream to the frontend
|
||||
self.job.results = {"results": [], "total_frames_processed": 0}
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: searching %d runs (parallel=%s, workers=%d)",
|
||||
self.job.id,
|
||||
len(runs),
|
||||
self.job.parallel,
|
||||
self.max_workers,
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
futures: dict[Future, int] = {}
|
||||
completed_segments: dict[int, tuple[list[MotionSearchResult], int]] = {}
|
||||
|
||||
if self.job.parallel and len(runs) > 1:
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
futures: dict[Future, int] = {}
|
||||
for idx, run in enumerate(runs):
|
||||
if self._should_stop():
|
||||
break
|
||||
futures[executor.submit(self._process_run, run)] = idx
|
||||
for idx, recording in enumerate(recordings):
|
||||
if self._should_stop():
|
||||
break
|
||||
|
||||
completed: dict[int, tuple[list[MotionSearchResult], int]] = {}
|
||||
next_idx = 0
|
||||
for future in as_completed(futures):
|
||||
if self._should_stop():
|
||||
break
|
||||
run_idx = futures[future]
|
||||
try:
|
||||
completed[run_idx] = future.result()
|
||||
except Exception as e:
|
||||
self.metrics.segments_with_errors += 1
|
||||
logger.warning("Error processing run %d: %s", run_idx, e)
|
||||
completed[run_idx] = ([], 0)
|
||||
rec_start: float = recording.start_time # type: ignore[assignment]
|
||||
rec_end: float = recording.end_time # type: ignore[assignment]
|
||||
future = executor.submit(
|
||||
self._process_recording_for_motion,
|
||||
str(recording.path),
|
||||
rec_start,
|
||||
rec_end,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
polygon_mask,
|
||||
self.job.threshold,
|
||||
self.job.min_area,
|
||||
self.job.frame_skip,
|
||||
)
|
||||
futures[future] = idx
|
||||
|
||||
while next_idx in completed:
|
||||
run_results, frames = completed.pop(next_idx)
|
||||
if self._merge_run(runs[next_idx], run_results, frames, state):
|
||||
for future in as_completed(futures):
|
||||
if self._should_stop():
|
||||
# Cancel remaining futures
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
recording_idx = futures[future]
|
||||
recording = recordings[recording_idx]
|
||||
|
||||
try:
|
||||
results, frames = future.result()
|
||||
self.metrics.segments_processed += 1
|
||||
completed_segments[recording_idx] = (results, frames)
|
||||
|
||||
while next_recording_idx_to_merge in completed_segments:
|
||||
segment_results, segment_frames = completed_segments.pop(
|
||||
next_recording_idx_to_merge
|
||||
)
|
||||
|
||||
all_results.extend(segment_results)
|
||||
total_frames += segment_frames
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
|
||||
if segment_results:
|
||||
deduped = self._deduplicate_results(all_results)
|
||||
self.job.results = {
|
||||
"results": [
|
||||
r.to_dict() for r in deduped[: self.job.max_results]
|
||||
],
|
||||
"total_frames_processed": total_frames,
|
||||
}
|
||||
|
||||
self._broadcast_status()
|
||||
|
||||
if segment_results and len(deduped) >= self.job.max_results:
|
||||
self.internal_stop_event.set()
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
for pending_future in futures:
|
||||
pending_future.cancel()
|
||||
break
|
||||
next_idx += 1
|
||||
|
||||
next_recording_idx_to_merge += 1
|
||||
|
||||
if self.internal_stop_event.is_set():
|
||||
break
|
||||
else:
|
||||
for run in runs:
|
||||
if self._should_stop():
|
||||
break
|
||||
try:
|
||||
run_results, frames = self._process_run(run)
|
||||
except Exception as e:
|
||||
self.metrics.segments_with_errors += 1
|
||||
self.metrics.segments_processed += len(run)
|
||||
self._broadcast_status()
|
||||
logger.warning("Error processing run: %s", e)
|
||||
continue
|
||||
if self._merge_run(run, run_results, frames, state):
|
||||
break
|
||||
|
||||
all_results: list[MotionSearchResult] = state["all_results"]
|
||||
self.job.total_frames_processed = state["total_frames"]
|
||||
self.metrics.frames_decoded = state["total_frames"]
|
||||
self.job.progress = 1.0
|
||||
except Exception as e:
|
||||
self.metrics.segments_processed += 1
|
||||
self.metrics.segments_with_errors += 1
|
||||
self._broadcast_status()
|
||||
logger.warning(
|
||||
"Error processing segment %s: %s",
|
||||
recording.path,
|
||||
e,
|
||||
)
|
||||
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: complete, %d raw results, %d frames, %d errors",
|
||||
"Motion search job %s: motion search complete, "
|
||||
"found %d raw results, decoded %d frames, %d segment errors",
|
||||
self.job.id,
|
||||
len(all_results),
|
||||
state["total_frames"],
|
||||
total_frames,
|
||||
self.metrics.segments_with_errors,
|
||||
)
|
||||
|
||||
all_results.sort(key=lambda r: r.timestamp)
|
||||
# Sort and deduplicate results
|
||||
all_results.sort(key=lambda x: x.timestamp)
|
||||
return self._deduplicate_results(all_results)[: self.job.max_results]
|
||||
|
||||
def _search_motion_sequential(
|
||||
self,
|
||||
recordings: list[Recordings],
|
||||
polygon_mask: np.ndarray,
|
||||
) -> list[MotionSearchResult]:
|
||||
"""Search for motion sequentially across segments, streaming results."""
|
||||
all_results: list[MotionSearchResult] = []
|
||||
total_frames = 0
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: starting sequential motion search across %d segments",
|
||||
self.job.id,
|
||||
len(recordings),
|
||||
)
|
||||
|
||||
self.job.results = {"results": [], "total_frames_processed": 0}
|
||||
|
||||
for recording in recordings:
|
||||
if self.cancel_event.is_set():
|
||||
break
|
||||
|
||||
try:
|
||||
rec_start: float = recording.start_time # type: ignore[assignment]
|
||||
rec_end: float = recording.end_time # type: ignore[assignment]
|
||||
results, frames = self._process_recording_for_motion(
|
||||
str(recording.path),
|
||||
rec_start,
|
||||
rec_end,
|
||||
self.job.start_time_range,
|
||||
self.job.end_time_range,
|
||||
polygon_mask,
|
||||
self.job.threshold,
|
||||
self.job.min_area,
|
||||
self.job.frame_skip,
|
||||
)
|
||||
all_results.extend(results)
|
||||
total_frames += frames
|
||||
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
self.metrics.segments_processed += 1
|
||||
|
||||
if results:
|
||||
all_results.sort(key=lambda x: x.timestamp)
|
||||
deduped = self._deduplicate_results(all_results)[
|
||||
: self.job.max_results
|
||||
]
|
||||
self.job.results = {
|
||||
"results": [r.to_dict() for r in deduped],
|
||||
"total_frames_processed": total_frames,
|
||||
}
|
||||
|
||||
self._broadcast_status()
|
||||
|
||||
if results and len(deduped) >= self.job.max_results:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.metrics.segments_processed += 1
|
||||
self.metrics.segments_with_errors += 1
|
||||
self._broadcast_status()
|
||||
logger.warning("Error processing segment %s: %s", recording.path, e)
|
||||
|
||||
self.job.total_frames_processed = total_frames
|
||||
self.metrics.frames_decoded = total_frames
|
||||
|
||||
logger.debug(
|
||||
"Motion search job %s: sequential motion search complete, "
|
||||
"found %d raw results, decoded %d frames, %d segment errors",
|
||||
self.job.id,
|
||||
len(all_results),
|
||||
total_frames,
|
||||
self.metrics.segments_with_errors,
|
||||
)
|
||||
|
||||
all_results.sort(key=lambda x: x.timestamp)
|
||||
return self._deduplicate_results(all_results)[: self.job.max_results]
|
||||
|
||||
def _deduplicate_results(
|
||||
@ -843,6 +602,160 @@ class MotionSearchRunner(threading.Thread):
|
||||
|
||||
return deduplicated
|
||||
|
||||
def _process_recording_for_motion(
|
||||
self,
|
||||
recording_path: str,
|
||||
recording_start: float,
|
||||
recording_end: float,
|
||||
search_start: float,
|
||||
search_end: float,
|
||||
polygon_mask: np.ndarray,
|
||||
threshold: int,
|
||||
min_area: float,
|
||||
frame_skip: int,
|
||||
) -> tuple[list[MotionSearchResult], int]:
|
||||
"""Process a single recording file for motion detection.
|
||||
|
||||
This method is designed to be called from a thread pool.
|
||||
|
||||
Args:
|
||||
min_area: Minimum change area as a percentage of the ROI (0-100).
|
||||
"""
|
||||
results: list[MotionSearchResult] = []
|
||||
frames_processed = 0
|
||||
|
||||
if not os.path.exists(recording_path):
|
||||
logger.warning("Recording file not found: %s", recording_path)
|
||||
return results, frames_processed
|
||||
|
||||
cap = cv2.VideoCapture(recording_path)
|
||||
if not cap.isOpened():
|
||||
logger.error("Could not open recording: %s", recording_path)
|
||||
return results, frames_processed
|
||||
|
||||
try:
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
recording_duration = recording_end - recording_start
|
||||
|
||||
# Calculate frame range
|
||||
start_offset = max(0, search_start - recording_start)
|
||||
end_offset = min(recording_duration, search_end - recording_start)
|
||||
start_frame = int(start_offset * fps)
|
||||
end_frame = int(end_offset * fps)
|
||||
start_frame = max(0, min(start_frame, total_frames - 1))
|
||||
end_frame = max(0, min(end_frame, total_frames))
|
||||
|
||||
if start_frame >= end_frame:
|
||||
return results, frames_processed
|
||||
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
||||
|
||||
# Get ROI bounding box
|
||||
roi_bbox = cv2.boundingRect(polygon_mask)
|
||||
roi_x, roi_y, roi_w, roi_h = roi_bbox
|
||||
|
||||
prev_frame_gray = None
|
||||
frame_step = max(frame_skip, 1)
|
||||
frame_idx = start_frame
|
||||
|
||||
while frame_idx < end_frame:
|
||||
if self._should_stop():
|
||||
break
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
frame_idx += 1
|
||||
continue
|
||||
|
||||
if (frame_idx - start_frame) % frame_step != 0:
|
||||
frame_idx += 1
|
||||
continue
|
||||
|
||||
frames_processed += 1
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# Handle frame dimension changes
|
||||
if gray.shape != polygon_mask.shape:
|
||||
resized_mask = cv2.resize(
|
||||
polygon_mask,
|
||||
(gray.shape[1], gray.shape[0]),
|
||||
interpolation=cv2.INTER_NEAREST,
|
||||
)
|
||||
current_bbox = cv2.boundingRect(resized_mask)
|
||||
else:
|
||||
resized_mask = polygon_mask
|
||||
current_bbox = roi_bbox
|
||||
|
||||
roi_x, roi_y, roi_w, roi_h = current_bbox
|
||||
cropped_gray = gray[roi_y : roi_y + roi_h, roi_x : roi_x + roi_w]
|
||||
cropped_mask = resized_mask[
|
||||
roi_y : roi_y + roi_h, roi_x : roi_x + roi_w
|
||||
]
|
||||
|
||||
cropped_mask_area = np.count_nonzero(cropped_mask)
|
||||
if cropped_mask_area == 0:
|
||||
frame_idx += 1
|
||||
continue
|
||||
|
||||
# Convert percentage to pixel count for this ROI
|
||||
min_area_pixels = int((min_area / 100.0) * cropped_mask_area)
|
||||
|
||||
masked_gray = cv2.bitwise_and(
|
||||
cropped_gray, cropped_gray, mask=cropped_mask
|
||||
)
|
||||
|
||||
if prev_frame_gray is not None:
|
||||
diff = cv2.absdiff(prev_frame_gray, masked_gray) # type: ignore[unreachable]
|
||||
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
|
||||
_, thresh = cv2.threshold(
|
||||
diff_blurred, threshold, 255, cv2.THRESH_BINARY
|
||||
)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=1)
|
||||
thresh_masked = cv2.bitwise_and(
|
||||
thresh_dilated, thresh_dilated, mask=cropped_mask
|
||||
)
|
||||
|
||||
change_pixels = cv2.countNonZero(thresh_masked)
|
||||
if change_pixels > min_area_pixels:
|
||||
contours, _ = cv2.findContours(
|
||||
thresh_masked, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
total_change_area = sum(
|
||||
cv2.contourArea(c)
|
||||
for c in contours
|
||||
if cv2.contourArea(c) >= min_area_pixels
|
||||
)
|
||||
if total_change_area > 0:
|
||||
frame_time_offset = (frame_idx - start_frame) / fps
|
||||
timestamp = (
|
||||
recording_start + start_offset + frame_time_offset
|
||||
)
|
||||
change_percentage = (
|
||||
total_change_area / cropped_mask_area
|
||||
) * 100
|
||||
results.append(
|
||||
MotionSearchResult(
|
||||
timestamp=timestamp,
|
||||
change_percentage=round(change_percentage, 2),
|
||||
)
|
||||
)
|
||||
|
||||
prev_frame_gray = masked_gray
|
||||
frame_idx += 1
|
||||
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
logger.debug(
|
||||
"Motion search segment complete: %s, %d frames processed, %d results found",
|
||||
recording_path,
|
||||
frames_processed,
|
||||
len(results),
|
||||
)
|
||||
return results, frames_processed
|
||||
|
||||
|
||||
# Module-level state for managing per-camera jobs
|
||||
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
|
||||
@ -866,6 +779,7 @@ def start_motion_search_job(
|
||||
polygon_points: list[list[float]],
|
||||
threshold: int = 30,
|
||||
min_area: float = 5.0,
|
||||
frame_skip: int = 5,
|
||||
parallel: bool = False,
|
||||
max_results: int = 25,
|
||||
) -> str:
|
||||
@ -880,6 +794,7 @@ def start_motion_search_job(
|
||||
polygon_points=polygon_points,
|
||||
threshold=threshold,
|
||||
min_area=min_area,
|
||||
frame_skip=frame_skip,
|
||||
parallel=parallel,
|
||||
max_results=max_results,
|
||||
)
|
||||
@ -897,13 +812,14 @@ def start_motion_search_job(
|
||||
logger.debug(
|
||||
"Started motion search job %s for camera %s: "
|
||||
"time_range=%.1f-%.1f, threshold=%d, min_area=%.1f%%, "
|
||||
"parallel=%s, max_results=%d, polygon_points=%d vertices",
|
||||
"frame_skip=%d, parallel=%s, max_results=%d, polygon_points=%d vertices",
|
||||
job.id,
|
||||
camera_name,
|
||||
start_time,
|
||||
end_time,
|
||||
threshold,
|
||||
min_area,
|
||||
frame_skip,
|
||||
parallel,
|
||||
max_results,
|
||||
len(polygon_points),
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
"""Pure helpers for VOD-batched motion search.
|
||||
|
||||
Coalescing gate-passing segments into time-contiguous runs, mapping a frame's
|
||||
VOD stream time back to an absolute timestamp, and thinning sample times to a
|
||||
target interval. No I/O or ffmpeg here so the tricky math stays unit-testable.
|
||||
"""
|
||||
|
||||
from bisect import bisect_right
|
||||
from typing import Any
|
||||
|
||||
|
||||
def coalesce_runs(
|
||||
segments: list[Any], max_seconds: float, epsilon: float
|
||||
) -> list[list[Any]]:
|
||||
"""Group gate-passing segments into time-contiguous runs.
|
||||
|
||||
A run extends while each segment's ``start_time`` is within ``epsilon`` of
|
||||
the previous segment's ``end_time`` (no recording gap) and the run's total
|
||||
span stays at or below ``max_seconds``. A gap or the cap starts a new run.
|
||||
Each segment must expose ``start_time`` / ``end_time``.
|
||||
"""
|
||||
runs: list[list[Any]] = []
|
||||
current: list[Any] = []
|
||||
for seg in segments:
|
||||
if not current:
|
||||
current = [seg]
|
||||
continue
|
||||
prev_end = float(current[-1].end_time)
|
||||
run_start = float(current[0].start_time)
|
||||
contiguous = abs(float(seg.start_time) - prev_end) <= epsilon
|
||||
within_cap = (float(seg.end_time) - run_start) <= max_seconds
|
||||
if contiguous and within_cap:
|
||||
current.append(seg)
|
||||
else:
|
||||
runs.append(current)
|
||||
current = [seg]
|
||||
if current:
|
||||
runs.append(current)
|
||||
return runs
|
||||
|
||||
|
||||
def build_segment_time_map(
|
||||
run: list[Any],
|
||||
) -> list[tuple[float, float, float]]:
|
||||
"""Build a (stream_offset, abs_start, duration) row per segment in a run.
|
||||
|
||||
``stream_offset`` is the segment's start in continuous VOD stream time (the
|
||||
cumulative sum of preceding segment durations); ``abs_start`` is its absolute
|
||||
``start_time``. Built from each segment's own duration; for a gap-free run
|
||||
this makes stream time equal ``run_start + offset``.
|
||||
"""
|
||||
rows: list[tuple[float, float, float]] = []
|
||||
offset = 0.0
|
||||
for seg in run:
|
||||
duration = float(seg.end_time) - float(seg.start_time)
|
||||
rows.append((offset, float(seg.start_time), duration))
|
||||
offset += duration
|
||||
return rows
|
||||
|
||||
|
||||
def stream_time_to_absolute(
|
||||
time_map: list[tuple[float, float, float]], stream_time: float
|
||||
) -> float:
|
||||
"""Map a VOD stream time to an absolute timestamp via the run's table.
|
||||
|
||||
Binary-searches the segment whose stream range contains ``stream_time`` and
|
||||
returns ``abs_start + (stream_time - stream_offset)``. Times past the last
|
||||
segment map into the last segment (clamped at the run edge).
|
||||
"""
|
||||
offsets = [row[0] for row in time_map]
|
||||
idx = bisect_right(offsets, stream_time) - 1
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
stream_offset, abs_start, _duration = time_map[idx]
|
||||
return abs_start + (stream_time - stream_offset)
|
||||
@ -1,382 +0,0 @@
|
||||
"""Hardware-accelerated ffmpeg decode for motion search.
|
||||
|
||||
Decodes a recording run's VOD/HLS stream with an ffmpeg subprocess, optionally
|
||||
selecting only keyframes, and streams raw frames over a pipe for the motion
|
||||
math. Output is the requested ``pix_fmt`` (gray or ``bgr24``) with optional
|
||||
crop/scale applied in the filter graph so downstream pixels are unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess as sp
|
||||
import tempfile
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import IO
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_decode
|
||||
from frigate.util.services import auto_detect_hwaccel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Output-format surfaces that download cleanly to nv12 via the fixed
|
||||
# ``hwdownload,format=nv12`` step the decode path appends. Other surfaces
|
||||
# (drm_prime from rkmpp, vulkan, amf) need a different download step, so motion
|
||||
# search decodes them in software to keep results byte-identical rather than risk
|
||||
# a wrong-but-valid-sized frame the zero-frame fallback gate would not catch.
|
||||
_NV12_OUTPUT_FORMATS = frozenset({"vaapi", "cuda", "qsv"})
|
||||
|
||||
|
||||
def _hwaccel_output_format(decode_args: list[str]) -> str | None:
|
||||
"""Return the ``-hwaccel_output_format`` value in ffmpeg args, or None."""
|
||||
try:
|
||||
idx = decode_args.index("-hwaccel_output_format")
|
||||
except ValueError:
|
||||
return None
|
||||
return decode_args[idx + 1] if idx + 1 < len(decode_args) else None
|
||||
|
||||
|
||||
def resolve_motion_decode_args(camera_config: CameraConfig) -> list[str]:
|
||||
"""Resolve the ffmpeg hwaccel decode args for a camera's recordings.
|
||||
|
||||
``auto`` is resolved via ``auto_detect_hwaccel`` and the preset is expanded
|
||||
by ``parse_preset_hardware_acceleration_decode`` (the same table the live
|
||||
pipeline uses). Acceleration is kept only when the decoded surface downloads
|
||||
cleanly to nv12 -- decided by reading ``-hwaccel_output_format`` back from the
|
||||
resolved args rather than a separate preset allowlist that could drift from
|
||||
``PRESETS_HW_ACCEL_DECODE``. Anything else (custom args, a software-only
|
||||
preset, or an nv12-incompatible surface) returns an empty list, meaning
|
||||
software decode, so results stay byte-identical.
|
||||
"""
|
||||
raw = camera_config.ffmpeg.hwaccel_args
|
||||
preset = auto_detect_hwaccel() if raw == "auto" else raw
|
||||
|
||||
# Custom args (a list) decode in software so results stay byte-identical.
|
||||
if not isinstance(preset, str):
|
||||
return []
|
||||
|
||||
decode_args = parse_preset_hardware_acceleration_decode(
|
||||
preset,
|
||||
camera_config.detect.fps,
|
||||
camera_config.detect.width or 0,
|
||||
camera_config.detect.height or 0,
|
||||
camera_config.ffmpeg.gpu,
|
||||
)
|
||||
if not decode_args:
|
||||
return []
|
||||
|
||||
if _hwaccel_output_format(decode_args) not in _NV12_OUTPUT_FORMATS:
|
||||
return []
|
||||
|
||||
return decode_args
|
||||
|
||||
|
||||
def _read_exact(stream: IO[bytes], size: int) -> bytes | None:
|
||||
"""Read exactly ``size`` bytes from a pipe, or None at clean EOF.
|
||||
|
||||
Pipe reads can return fewer bytes than requested, so loop until the frame
|
||||
is complete. A short read at the start of a frame means end-of-stream.
|
||||
"""
|
||||
buf = bytearray()
|
||||
while len(buf) < size:
|
||||
chunk = stream.read(size - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _terminate(proc: sp.Popen[bytes]) -> None:
|
||||
"""Stop an ffmpeg decode process promptly."""
|
||||
# Close the read end first so a blocked ffmpeg write unblocks (ffmpeg then
|
||||
# sees a broken pipe), then signal it. The resulting ffmpeg write error is
|
||||
# harmless and goes to the captured stderr.
|
||||
if proc.stdout is not None:
|
||||
try:
|
||||
proc.stdout.close()
|
||||
except OSError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except sp.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
KEYFRAME_MAX_GAP_SECONDS = 2.0
|
||||
|
||||
|
||||
def keyframe_sampling_eligible(
|
||||
keyframe_pts: list[float], max_gap: float = KEYFRAME_MAX_GAP_SECONDS
|
||||
) -> bool:
|
||||
"""True if keyframes are dense and regular enough for keyframe-only sampling.
|
||||
|
||||
Requires at least two keyframes and no gap longer than ``max_gap`` seconds, so
|
||||
a multi-second motion event necessarily spans a sampled keyframe.
|
||||
"""
|
||||
if len(keyframe_pts) < 2:
|
||||
return False
|
||||
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
|
||||
return max(gaps) <= max_gap
|
||||
|
||||
|
||||
VOD_PROTOCOL_ARGS = ["-protocol_whitelist", "pipe,file,http,tcp"]
|
||||
|
||||
|
||||
def build_vod_decode_command(
|
||||
ffmpeg_path: str,
|
||||
vod_url: str,
|
||||
decode_args: list[str],
|
||||
crop: tuple[int, int, int, int] | None,
|
||||
scale: tuple[int, int] | None,
|
||||
gray: bool,
|
||||
*,
|
||||
skip_nonkey: bool,
|
||||
fps_rate: float | None,
|
||||
) -> list[str]:
|
||||
"""Build the ffmpeg argv to decode a VOD HLS URL.
|
||||
|
||||
``skip_nonkey`` adds ``-skip_frame nokey`` (keyframe-only). ``fps_rate`` adds
|
||||
an ``fps`` filter for the fixed-cadence fallback. They are mutually
|
||||
exclusive: keyframe mode passes ``skip_nonkey=True``/``fps_rate=None``; the
|
||||
fallback passes ``skip_nonkey=False`` with a rate.
|
||||
"""
|
||||
filters: list[str] = []
|
||||
# With hwaccel the decoded frames are GPU surfaces; pull them back to system
|
||||
# memory before the CPU fps/crop/scale filters and the rawvideo encoder.
|
||||
if decode_args:
|
||||
filters.append("hwdownload")
|
||||
filters.append("format=nv12")
|
||||
if fps_rate is not None:
|
||||
filters.append(f"fps={fps_rate}")
|
||||
if crop is not None:
|
||||
cw, ch, cx, cy = crop
|
||||
filters.append(f"crop={cw}:{ch}:{cx}:{cy}")
|
||||
if scale is not None:
|
||||
sw, sh = scale
|
||||
filters.append(f"scale={sw}:{sh}")
|
||||
|
||||
pix_fmt = "gray" if gray else "bgr24"
|
||||
cmd = [ffmpeg_path, "-hide_banner", "-loglevel", "error"]
|
||||
if skip_nonkey:
|
||||
cmd += ["-skip_frame", "nokey"]
|
||||
cmd += [*decode_args, *VOD_PROTOCOL_ARGS, "-i", vod_url, "-an"]
|
||||
if filters:
|
||||
cmd += ["-vf", ",".join(filters)]
|
||||
cmd += ["-vsync", "0", "-f", "rawvideo", "-pix_fmt", pix_fmt, "pipe:"]
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_vod_decode(
|
||||
ffmpeg_path: str,
|
||||
vod_url: str,
|
||||
out_width: int,
|
||||
out_height: int,
|
||||
channels: int,
|
||||
decode_args: list[str],
|
||||
crop: tuple[int, int, int, int] | None,
|
||||
scale: tuple[int, int] | None,
|
||||
gray: bool,
|
||||
should_stop: Callable[[], bool],
|
||||
*,
|
||||
skip_nonkey: bool,
|
||||
fps_rate: float | None,
|
||||
software_retry: bool,
|
||||
) -> Generator[np.ndarray, None, None]:
|
||||
"""Run one VOD decode, yielding raw frames; retry in software if empty."""
|
||||
cmd = build_vod_decode_command(
|
||||
ffmpeg_path,
|
||||
vod_url,
|
||||
decode_args,
|
||||
crop,
|
||||
scale,
|
||||
gray,
|
||||
skip_nonkey=skip_nonkey,
|
||||
fps_rate=fps_rate,
|
||||
)
|
||||
frame_size = out_width * out_height * channels
|
||||
stderr_file = tempfile.SpooledTemporaryFile(max_size=65536)
|
||||
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=stderr_file)
|
||||
assert proc.stdout is not None
|
||||
|
||||
count = 0
|
||||
try:
|
||||
while True:
|
||||
if should_stop():
|
||||
break
|
||||
buf = _read_exact(proc.stdout, frame_size)
|
||||
if buf is None:
|
||||
break
|
||||
if channels == 1:
|
||||
frame = np.frombuffer(buf, dtype=np.uint8).reshape(
|
||||
(out_height, out_width)
|
||||
)
|
||||
else:
|
||||
frame = np.frombuffer(buf, dtype=np.uint8).reshape(
|
||||
(out_height, out_width, channels)
|
||||
)
|
||||
count += 1
|
||||
yield frame
|
||||
finally:
|
||||
_terminate(proc)
|
||||
stderr_file.close()
|
||||
|
||||
if count == 0 and software_retry and not should_stop():
|
||||
logger.warning("Hardware VOD decode produced no frames, retrying in software")
|
||||
yield from _run_vod_decode(
|
||||
ffmpeg_path,
|
||||
vod_url,
|
||||
out_width,
|
||||
out_height,
|
||||
channels,
|
||||
[],
|
||||
crop,
|
||||
scale,
|
||||
gray,
|
||||
should_stop,
|
||||
skip_nonkey=skip_nonkey,
|
||||
fps_rate=fps_rate,
|
||||
software_retry=False,
|
||||
)
|
||||
|
||||
|
||||
def iter_vod_frames(
|
||||
ffmpeg_path: str,
|
||||
vod_url: str,
|
||||
out_width: int,
|
||||
out_height: int,
|
||||
channels: int,
|
||||
decode_args: list[str],
|
||||
crop: tuple[int, int, int, int] | None,
|
||||
scale: tuple[int, int] | None,
|
||||
gray: bool,
|
||||
should_stop: Callable[[], bool],
|
||||
*,
|
||||
skip_nonkey: bool,
|
||||
fps_rate: float | None,
|
||||
) -> Generator[np.ndarray, None, None]:
|
||||
"""Decode a VOD HLS URL and yield raw frames in order.
|
||||
|
||||
Pair keyframe-mode output with probed keyframe PTS; pair fallback output with
|
||||
a fixed cadence. Falls back once to software decode if a hwaccel decode yields
|
||||
no frames.
|
||||
"""
|
||||
yield from _run_vod_decode(
|
||||
ffmpeg_path,
|
||||
vod_url,
|
||||
out_width,
|
||||
out_height,
|
||||
channels,
|
||||
decode_args,
|
||||
crop,
|
||||
scale,
|
||||
gray,
|
||||
should_stop,
|
||||
skip_nonkey=skip_nonkey,
|
||||
fps_rate=fps_rate,
|
||||
software_retry=bool(decode_args),
|
||||
)
|
||||
|
||||
|
||||
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
|
||||
"""Return keyframe presentation timestamps (VOD stream time) in order.
|
||||
|
||||
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
|
||||
any failure so the caller can fall back.
|
||||
"""
|
||||
cmd = [
|
||||
ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
*VOD_PROTOCOL_ARGS,
|
||||
"-i",
|
||||
vod_url,
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_packets",
|
||||
"-show_entries",
|
||||
"packet=pts_time,flags",
|
||||
"-of",
|
||||
"json",
|
||||
]
|
||||
try:
|
||||
completed = sp.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except (OSError, sp.SubprocessError):
|
||||
logger.warning("ffprobe failed for VOD keyframe probe")
|
||||
return []
|
||||
|
||||
if completed.returncode != 0 or not completed.stdout:
|
||||
return []
|
||||
|
||||
try:
|
||||
packets = json.loads(completed.stdout).get("packets", [])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
pts: list[float] = []
|
||||
for pkt in packets:
|
||||
flags = pkt.get("flags", "")
|
||||
pts_time = pkt.get("pts_time")
|
||||
if flags.startswith("K") and pts_time is not None:
|
||||
try:
|
||||
pts.append(float(pts_time))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(pts)
|
||||
|
||||
|
||||
def probe_video_dimensions(
|
||||
ffprobe_path: str, recording_path: str
|
||||
) -> tuple[int, int, float] | None:
|
||||
"""Return (width, height, fps) for a recording's video stream, or None.
|
||||
|
||||
Reads stream metadata via ffprobe (no decode). The record stream resolution
|
||||
can differ from the camera's detect resolution, so this is probed once per
|
||||
job against a real segment.
|
||||
"""
|
||||
cmd = [
|
||||
ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height,avg_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
recording_path,
|
||||
]
|
||||
try:
|
||||
completed = sp.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
except (OSError, sp.SubprocessError):
|
||||
return None
|
||||
|
||||
if completed.returncode != 0 or not completed.stdout:
|
||||
return None
|
||||
|
||||
try:
|
||||
streams = json.loads(completed.stdout).get("streams", [])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not streams:
|
||||
return None
|
||||
|
||||
stream = streams[0]
|
||||
width = int(stream.get("width", 0) or 0)
|
||||
height = int(stream.get("height", 0) or 0)
|
||||
rate = stream.get("avg_frame_rate", "0/0") or "0/0"
|
||||
try:
|
||||
num, _, den = rate.partition("/")
|
||||
fps = float(num) / float(den) if float(den) != 0 else 0.0
|
||||
except (ValueError, ZeroDivisionError):
|
||||
fps = 0.0
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
|
||||
return width, height, fps
|
||||
@ -403,75 +403,3 @@ class TestHttpMedia(BaseTestHttp):
|
||||
assert len(summary) == 1
|
||||
assert "2024-03-10" in summary
|
||||
assert summary["2024-03-10"] is True
|
||||
|
||||
def test_recordings_unavailable_reports_gap_between_recordings(self):
|
||||
"""A gap between two recordings is reported as an unavailable segment."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Two recordings with a 20s gap (1010-1030) between them.
|
||||
Recordings.insert(
|
||||
id="rec_a",
|
||||
path="/media/recordings/a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1010,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="rec_b",
|
||||
path="/media/recordings/b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1030,
|
||||
end_time=1040,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1040,
|
||||
"scale": 5,
|
||||
"cameras": "front_door",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
|
||||
|
||||
def test_recordings_unavailable_merges_overlapping_recordings(self):
|
||||
"""Overlapping recordings are merged so no false gap is reported."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Overlapping recordings spanning the whole requested range.
|
||||
Recordings.insert(
|
||||
id="rec_a",
|
||||
path="/media/recordings/a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1020,
|
||||
duration=20,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="rec_b",
|
||||
path="/media/recordings/b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1010,
|
||||
end_time=1030,
|
||||
duration=20,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1030,
|
||||
"scale": 5,
|
||||
"cameras": "front_door",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@ -610,16 +610,19 @@ class TestHttpReview(BaseTestHttp):
|
||||
response = client.get("/review/activity/motion", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
# Only buckets with an actual recording are returned. Empty
|
||||
# gap-fill buckets between the two recordings are dropped.
|
||||
assert len(response_json) == 2
|
||||
assert len(response_json) == 61
|
||||
self.assertDictEqual(
|
||||
{"motion": 50.5, "camera": "front_door", "start_time": now + 1},
|
||||
response_json[0],
|
||||
)
|
||||
for item in response_json[1:-1]:
|
||||
self.assertDictEqual(
|
||||
{"motion": 0.0, "camera": "", "start_time": item["start_time"]},
|
||||
item,
|
||||
)
|
||||
self.assertDictEqual(
|
||||
{"motion": 100.0, "camera": "front_door", "start_time": one_m + 1},
|
||||
response_json[1],
|
||||
response_json[len(response_json) - 1],
|
||||
)
|
||||
|
||||
####################################################################################################################
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
"""Tests for motion search batch helpers (runs + timestamp mapping)."""
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from frigate.jobs.motion_search_batch import (
|
||||
build_segment_time_map,
|
||||
coalesce_runs,
|
||||
stream_time_to_absolute,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Seg:
|
||||
path: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
|
||||
|
||||
def _run_seconds(run):
|
||||
return float(run[-1].end_time) - float(run[0].start_time)
|
||||
|
||||
|
||||
class TestCoalesceRuns(unittest.TestCase):
|
||||
def test_contiguous_segments_form_one_run(self):
|
||||
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 20.0, 30.0)]
|
||||
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
|
||||
self.assertEqual(len(runs), 1)
|
||||
self.assertEqual(len(runs[0]), 3)
|
||||
|
||||
def test_time_gap_splits_runs(self):
|
||||
# b ends 20, c starts 25 -> 5s gap > epsilon -> two runs.
|
||||
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 25.0, 35.0)]
|
||||
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
|
||||
self.assertEqual([len(r) for r in runs], [2, 1])
|
||||
|
||||
def test_max_duration_caps_a_run(self):
|
||||
# Five contiguous 10s segments, cap 25s.
|
||||
segs = [_Seg(str(i), i * 10.0, i * 10.0 + 10.0) for i in range(5)]
|
||||
runs = coalesce_runs(segs, max_seconds=25.0, epsilon=0.5)
|
||||
self.assertTrue(all(_run_seconds(r) <= 30.0 for r in runs))
|
||||
self.assertEqual(sum(len(r) for r in runs), 5)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(coalesce_runs([], max_seconds=600.0, epsilon=0.5), [])
|
||||
|
||||
|
||||
class TestTimestampMapping(unittest.TestCase):
|
||||
def test_gapfree_run_maps_to_start_plus_pts(self):
|
||||
run = [_Seg("a", 1000.0, 1010.0), _Seg("b", 1010.0, 1020.0)]
|
||||
time_map = build_segment_time_map(run)
|
||||
self.assertAlmostEqual(stream_time_to_absolute(time_map, 3.0), 1003.0)
|
||||
self.assertAlmostEqual(stream_time_to_absolute(time_map, 12.0), 1012.0)
|
||||
|
||||
def test_past_end_clamps(self):
|
||||
run = [_Seg("a", 1000.0, 1010.0)]
|
||||
time_map = build_segment_time_map(run)
|
||||
self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9)
|
||||
@ -1,190 +0,0 @@
|
||||
"""Tests for the motion search hardware-accelerated decode helpers."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from frigate.jobs.motion_search_decode import (
|
||||
KEYFRAME_MAX_GAP_SECONDS,
|
||||
build_vod_decode_command,
|
||||
keyframe_sampling_eligible,
|
||||
probe_video_dimensions,
|
||||
probe_vod_keyframe_pts,
|
||||
resolve_motion_decode_args,
|
||||
)
|
||||
|
||||
|
||||
def _fake_camera_config(
|
||||
hwaccel_args, gpu=0, fps=5, width=1280, height=720, ffmpeg_path="ffmpeg"
|
||||
):
|
||||
return SimpleNamespace(
|
||||
ffmpeg=SimpleNamespace(
|
||||
hwaccel_args=hwaccel_args, gpu=gpu, ffmpeg_path=ffmpeg_path
|
||||
),
|
||||
detect=SimpleNamespace(fps=fps, width=width, height=height),
|
||||
)
|
||||
|
||||
|
||||
class TestResolveMotionDecodeArgs(unittest.TestCase):
|
||||
def test_vaapi_preset_is_accelerated(self):
|
||||
args = resolve_motion_decode_args(_fake_camera_config("preset-vaapi"))
|
||||
self.assertIn("-hwaccel", args)
|
||||
self.assertIn("vaapi", args)
|
||||
|
||||
def test_non_nv12_preset_falls_back_to_software(self):
|
||||
# rkmpp produces drm_prime surfaces that do not download to nv12, so it
|
||||
# must resolve to software decode (empty args) rather than risk corrupt
|
||||
# frames.
|
||||
self.assertEqual(
|
||||
resolve_motion_decode_args(_fake_camera_config("preset-rkmpp")), []
|
||||
)
|
||||
|
||||
def test_custom_args_fall_back_to_software(self):
|
||||
# Arbitrary custom hwaccel args (a list, not a preset) decode in software
|
||||
# to preserve byte-identical results.
|
||||
self.assertEqual(
|
||||
resolve_motion_decode_args(_fake_camera_config(["-hwaccel", "vulkan"])),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_nvidia_codec_preset_is_accelerated(self):
|
||||
# Codec-specific nvidia presets resolve to the same cuda decode args as
|
||||
# the bare preset, so eligibility is derived from -hwaccel_output_format
|
||||
# rather than a hardcoded list that omitted these aliases.
|
||||
args = resolve_motion_decode_args(_fake_camera_config("preset-nvidia-h264"))
|
||||
self.assertIn("-hwaccel_output_format", args)
|
||||
self.assertIn("cuda", args)
|
||||
|
||||
def test_software_only_preset_falls_back_to_software(self):
|
||||
# A preset with no -hwaccel_output_format (decoder-based, no GPU surface)
|
||||
# cannot use the nv12 download step, so it decodes in software.
|
||||
self.assertEqual(
|
||||
resolve_motion_decode_args(_fake_camera_config("preset-rpi-64-h264")), []
|
||||
)
|
||||
|
||||
|
||||
class TestKeyframeEligibility(unittest.TestCase):
|
||||
def test_regular_short_gop_is_eligible(self):
|
||||
pts = [0.0, 0.5, 1.0, 1.5, 2.0] # 0.5s gaps
|
||||
self.assertTrue(keyframe_sampling_eligible(pts))
|
||||
|
||||
def test_long_gop_is_ineligible(self):
|
||||
pts = [0.0, 5.0, 10.0] # 5s gaps
|
||||
self.assertFalse(keyframe_sampling_eligible(pts))
|
||||
|
||||
def test_irregular_gop_ineligible_when_a_gap_is_long(self):
|
||||
pts = [0.0, 0.5, 1.0, 8.0] # one 7s gap
|
||||
self.assertFalse(keyframe_sampling_eligible(pts))
|
||||
|
||||
def test_too_few_keyframes_ineligible(self):
|
||||
self.assertFalse(keyframe_sampling_eligible([1.0]))
|
||||
self.assertFalse(keyframe_sampling_eligible([]))
|
||||
|
||||
def test_default_max_gap_constant(self):
|
||||
self.assertEqual(KEYFRAME_MAX_GAP_SECONDS, 2.0)
|
||||
|
||||
|
||||
class TestVodDecodeCommand(unittest.TestCase):
|
||||
URL = "http://127.0.0.1:5000/vod/cam/start/1/end/2/index.m3u8"
|
||||
|
||||
def test_keyframe_command_shape(self):
|
||||
cmd = build_vod_decode_command(
|
||||
"ffmpeg",
|
||||
self.URL,
|
||||
decode_args=[],
|
||||
crop=(100, 80, 10, 20),
|
||||
scale=(50, 40),
|
||||
gray=True,
|
||||
skip_nonkey=True,
|
||||
fps_rate=None,
|
||||
)
|
||||
joined = " ".join(cmd)
|
||||
self.assertIn("-skip_frame nokey", joined)
|
||||
self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined)
|
||||
self.assertIn(f"-i {self.URL}", joined)
|
||||
self.assertIn("crop=100:80:10:20", joined)
|
||||
self.assertIn("scale=50:40", joined)
|
||||
self.assertIn("-pix_fmt gray", joined)
|
||||
self.assertNotIn("fps=", joined)
|
||||
|
||||
def test_fps_command_uses_fps_filter_not_skip_frame(self):
|
||||
cmd = build_vod_decode_command(
|
||||
"ffmpeg",
|
||||
self.URL,
|
||||
decode_args=[],
|
||||
crop=None,
|
||||
scale=None,
|
||||
gray=False,
|
||||
skip_nonkey=False,
|
||||
fps_rate=2.0,
|
||||
)
|
||||
joined = " ".join(cmd)
|
||||
self.assertNotIn("skip_frame", joined)
|
||||
self.assertIn("fps=2.0", joined)
|
||||
self.assertIn("-pix_fmt bgr24", joined)
|
||||
|
||||
def test_hwaccel_inserts_hwdownload(self):
|
||||
cmd = build_vod_decode_command(
|
||||
"ffmpeg",
|
||||
self.URL,
|
||||
decode_args=["-hwaccel", "vaapi"],
|
||||
crop=None,
|
||||
scale=None,
|
||||
gray=True,
|
||||
skip_nonkey=True,
|
||||
fps_rate=None,
|
||||
)
|
||||
joined = " ".join(cmd)
|
||||
self.assertIn("hwdownload", joined)
|
||||
self.assertIn("format=nv12", joined)
|
||||
|
||||
|
||||
class TestProbeVodKeyframePts(unittest.TestCase):
|
||||
def test_parses_keyframe_packets(self):
|
||||
sample = (
|
||||
'{"packets":['
|
||||
'{"pts_time":"0.000000","flags":"K__"},'
|
||||
'{"pts_time":"1.000000","flags":"___"},'
|
||||
'{"pts_time":"2.000000","flags":"K__"}]}'
|
||||
)
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
|
||||
self.assertEqual(pts, [0.0, 2.0])
|
||||
|
||||
def test_returns_empty_on_failure(self):
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
|
||||
|
||||
|
||||
class TestProbeVideoDimensions(unittest.TestCase):
|
||||
def test_parses_dimensions_and_fps(self):
|
||||
sample = (
|
||||
'{"streams":[{"width":1920,"height":1080,"avg_frame_rate":"30000/1001"}]}'
|
||||
)
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4")
|
||||
assert dims is not None
|
||||
width, height, fps = dims
|
||||
self.assertEqual((width, height), (1920, 1080))
|
||||
self.assertAlmostEqual(fps, 29.97, places=2)
|
||||
|
||||
def test_returns_none_on_zero_dimensions(self):
|
||||
sample = '{"streams":[{"width":0,"height":0,"avg_frame_rate":"0/0"}]}'
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
self.assertIsNone(probe_video_dimensions("ffprobe", "/tmp/a.mp4"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,87 +0,0 @@
|
||||
"""Tests for motion search spatial (crop/scale/mask) helpers."""
|
||||
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.jobs.motion_search import (
|
||||
build_scaled_roi_mask,
|
||||
compute_roi_crop_and_scale,
|
||||
detect_motion_scaled,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeRoiCropAndScale(unittest.TestCase):
|
||||
def test_crop_box_in_record_pixels(self):
|
||||
# ROI covering x [0.25, 0.75], y [0.5, 1.0] of a 1000x600 frame.
|
||||
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
|
||||
cw, ch, cx, cy = crop
|
||||
self.assertEqual((cx, cy), (250, 300))
|
||||
self.assertEqual((cw, ch), (500, 300))
|
||||
# longest side 500 -> factor 0.25 -> (125, 75), rounded down to even.
|
||||
self.assertEqual(scaled, (124, 74))
|
||||
|
||||
def test_never_upscales(self):
|
||||
polygon = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 200, 200, scale_target=400)
|
||||
cw, ch, _, _ = crop
|
||||
# crop is 20x20; target 400 would upscale, so scaled == crop size.
|
||||
self.assertEqual(scaled, (cw, ch))
|
||||
|
||||
def test_scaled_dims_are_at_least_one(self):
|
||||
polygon = [[0.0, 0.0], [0.02, 0.0], [0.02, 0.02], [0.0, 0.02]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 50, 50, scale_target=1)
|
||||
self.assertGreaterEqual(scaled[0], 1)
|
||||
self.assertGreaterEqual(scaled[1], 1)
|
||||
|
||||
def test_all_dims_are_even_for_nv12(self):
|
||||
# Odd-aligned ROI on an odd-ish frame must still yield even crop/scale so
|
||||
# the nv12 hwdownload byte stream matches the expected frame size.
|
||||
polygon = [[0.123, 0.321], [0.777, 0.321], [0.777, 0.901], [0.123, 0.901]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 1377, 911, scale_target=257)
|
||||
for value in (*crop, *scaled):
|
||||
self.assertEqual(value % 2, 0, f"{value} is not even")
|
||||
|
||||
|
||||
class TestBuildScaledRoiMask(unittest.TestCase):
|
||||
def test_mask_matches_scaled_dims_and_has_coverage(self):
|
||||
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
|
||||
mask = build_scaled_roi_mask(polygon, 1000, 600, crop, scaled)
|
||||
self.assertEqual(mask.shape, (scaled[1], scaled[0]))
|
||||
self.assertEqual(mask.dtype, np.uint8)
|
||||
# A full rectangle ROI fills its whole crop -> mask is all 255.
|
||||
self.assertGreater(np.count_nonzero(mask), 0)
|
||||
self.assertEqual(np.count_nonzero(mask), mask.size)
|
||||
|
||||
|
||||
class TestDetectMotionScaled(unittest.TestCase):
|
||||
def _ts(self, idx):
|
||||
return float(idx)
|
||||
|
||||
def test_finds_change_between_frames(self):
|
||||
mask = np.full((60, 80), 255, dtype=np.uint8)
|
||||
f0 = np.zeros((60, 80), dtype=np.uint8)
|
||||
f1 = np.zeros((60, 80), dtype=np.uint8)
|
||||
f1[10:50, 20:60] = 255 # big bright block appears
|
||||
frames = [(0, f0), (30, f1)]
|
||||
results = detect_motion_scaled(
|
||||
frames, mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].timestamp, 30.0)
|
||||
self.assertGreater(results[0].change_percentage, 0.0)
|
||||
|
||||
def test_no_change_yields_nothing(self):
|
||||
mask = np.full((60, 80), 255, dtype=np.uint8)
|
||||
f0 = np.zeros((60, 80), dtype=np.uint8)
|
||||
f1 = np.zeros((60, 80), dtype=np.uint8)
|
||||
results = detect_motion_scaled(
|
||||
[(0, f0), (30, f1)], mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
|
||||
)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1,6 +1,5 @@
|
||||
"""Tests for the profiles system."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
@ -747,36 +746,6 @@ class TestProfileManager(unittest.TestCase):
|
||||
manager.activate_profile(None)
|
||||
dispatcher.clear_runtime_state.assert_called_once_with()
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_profile_change_republishes_switch_states(self, mock_persist):
|
||||
"""Profile changes republish MQTT switch states so HA stays in sync.
|
||||
|
||||
Regression: activating/deactivating a profile updated the in-memory
|
||||
config (and Frigate's behavior) but left the retained MQTT state
|
||||
topics stale, so external integrations like Home Assistant kept
|
||||
showing the pre-profile toggle position.
|
||||
"""
|
||||
config_data = copy.deepcopy(self.config_data)
|
||||
config_data["cameras"]["front"]["profiles"]["disarmed"]["review"] = {
|
||||
"alerts": {"enabled": False},
|
||||
}
|
||||
config = FrigateConfig(**config_data)
|
||||
dispatcher = MagicMock()
|
||||
manager = ProfileManager(config, self.mock_updater, dispatcher)
|
||||
|
||||
# Activating disarmed turns alerts off -> MQTT state must follow
|
||||
manager.activate_profile("disarmed")
|
||||
dispatcher.publish.assert_any_call(
|
||||
"front/review_alerts/state", "OFF", retain=True
|
||||
)
|
||||
|
||||
# Deactivating restores the base (alerts on) -> MQTT state must follow
|
||||
dispatcher.publish.reset_mock()
|
||||
manager.activate_profile(None)
|
||||
dispatcher.publish.assert_any_call(
|
||||
"front/review_alerts/state", "ON", retain=True
|
||||
)
|
||||
|
||||
@patch.object(ProfileManager, "_persist_active_profile")
|
||||
def test_startup_replay_does_not_clear_runtime_state(self, mock_persist):
|
||||
"""Startup callers pass clear_runtime_overrides=False to preserve state."""
|
||||
|
||||
@ -618,16 +618,6 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
||||
|
||||
new_config["cameras"][name] = camera_config
|
||||
|
||||
# Remove deprecated date_style and time_style from global ui config
|
||||
global_ui = new_config.get("ui", {})
|
||||
if global_ui.get("date_style") is not None:
|
||||
del new_config["ui"]["date_style"]
|
||||
if global_ui.get("time_style") is not None:
|
||||
del new_config["ui"]["time_style"]
|
||||
# Remove ui section if empty
|
||||
if "ui" in new_config and not new_config["ui"]:
|
||||
del new_config["ui"]
|
||||
|
||||
new_config["version"] = "0.18-0"
|
||||
return new_config
|
||||
|
||||
|
||||
@ -686,7 +686,7 @@
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Estil de la marca horària",
|
||||
"description": "Opcions d'estilització per a marques de temps aplicades instantànies i la vista de depuració.",
|
||||
"description": "Opcions d'estilització per a marques de temps d'alimentació aplicades a enregistraments i instantànies.",
|
||||
"position": {
|
||||
"label": "Posició de la marca horària",
|
||||
"description": "Posició de la marca horària a la imatge (tl/tr/bl/br)."
|
||||
|
||||
@ -984,7 +984,7 @@
|
||||
},
|
||||
"default_role": {
|
||||
"label": "Rol predeterminat",
|
||||
"description": "Rol predeterminat assignat als usuaris intermediaris autenticats quan no s'aplica cap mapatge de rols."
|
||||
"description": "Rol predeterminat assignat als usuaris intermediaris autenticats quan no s'aplica cap mapatge de rols (administrador o visor)."
|
||||
},
|
||||
"separator": {
|
||||
"label": "Caràcter separador",
|
||||
|
||||
@ -26,9 +26,7 @@
|
||||
"points_many": "{{count}} punts",
|
||||
"points_other": "{{count}} punts",
|
||||
"undo": "Desfés l'últim punt",
|
||||
"reset": "Restableix el polígon",
|
||||
"drawMode": "Dibuxa",
|
||||
"moveMode": "Moure"
|
||||
"reset": "Restableix el polígon"
|
||||
},
|
||||
"motionHeatmapLabel": "Mapa de calor del moviment",
|
||||
"dialog": {
|
||||
@ -44,11 +42,11 @@
|
||||
"settings": {
|
||||
"title": "Configuració de la cerca",
|
||||
"parallelMode": "Mode paral·lel",
|
||||
"parallelModeDesc": "Escaneja múltiples intervals d'enregistrament al mateix temps (més ràpid; utilitza més recursos de descodificació)",
|
||||
"parallelModeDesc": "Escaneja múltiples segments d'enregistrament al mateix temps (més ràpid, però significativament més intensiu en CPU)",
|
||||
"threshold": "Llindar de la sensibilitat",
|
||||
"thresholdDesc": "Els valors més baixos detecten canvis més petits (1-255)",
|
||||
"minArea": "Àrea de canvi mínim",
|
||||
"minAreaDesc": "Mida mínima d'una sola regió en moviment, com a percentatge de la regió d'interès",
|
||||
"minAreaDesc": "Percentatge mínim de la regió d'interès que s'ha de canviar per considerar-se significatiu",
|
||||
"frameSkip": "Omet el fotograma",
|
||||
"frameSkipDesc": "Processa cada N fotograma. Establiu això a la velocitat de fotogrames de la càmera per processar un fotograma per segon (p. ex. 5 per a una càmera de 5 FPS, 30 per a una càmera de 30 FPS). Els valors més alts seran més ràpids, però poden perdre els esdeveniments de curt moviment.",
|
||||
"maxResults": "Resultats màxims",
|
||||
@ -74,9 +72,6 @@
|
||||
"framesDecoded": "Fotogrames descodificats",
|
||||
"wallTime": "Temps de cerca",
|
||||
"segmentErrors": "Errors del segment",
|
||||
"seconds": "{{seconds}}s",
|
||||
"scanSummary": "{{segments}} segments · {{time}}",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s"
|
||||
},
|
||||
"scanning": "S'està analitzant {{time}}"
|
||||
"seconds": "{{seconds}}s"
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,8 +426,7 @@
|
||||
"notificationUnavailable": {
|
||||
"title": "Notificacions no disponibles",
|
||||
"documentation": "Llegir la documentació",
|
||||
"desc": "Les notificacions push web requereixen un context segur (<code>https://…</code>). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions.",
|
||||
"descPwa": "A iOS, les notificacions push web només estàn disponibles quan Frigate està instalat a la pantalla principal. Obre el menú <strong>Compartir</strong> , selecciona <strong>Afegir a la pantalla</strong>, i obre Frigate des del nou icona per registrar les notificacions en aquest dispositiu."
|
||||
"desc": "Les notificacions push web requereixen un context segur (<code>https://…</code>). Aquesta és una limitació del navegador. Accedeix a Frigate de manera segura per utilitzar les notificacions."
|
||||
},
|
||||
"unsavedChanges": "Canvis de notificació no desats",
|
||||
"globalSettings": {
|
||||
@ -1829,17 +1828,6 @@
|
||||
"availableStreams": "Fluxos disponibles",
|
||||
"useCustom": "Utilitza \"{{value}}\"",
|
||||
"addStream": "Afegeix un flux"
|
||||
},
|
||||
"ptzPresets": {
|
||||
"placeholder": "Selecciona o entra una configuració...",
|
||||
"search": "Busca o entra una configuració...",
|
||||
"noPresets": "No hi ha configuracions disponibles",
|
||||
"available": "Parámetres de Cámera",
|
||||
"useCustom": "Usa \"{{value}}\""
|
||||
},
|
||||
"defaultRole": {
|
||||
"admin": "Administrar",
|
||||
"viewer": "Visor"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
@ -2005,8 +1993,7 @@
|
||||
"hardwareDxva2": "DXVA2",
|
||||
"hardwareVideotoolbox": "VideoToolbox"
|
||||
},
|
||||
"streamNumber": "Flux {{index}}",
|
||||
"sourceNumber": "Font {{index}}"
|
||||
"streamNumber": "Flux {{index}}"
|
||||
},
|
||||
"timestampPosition": {
|
||||
"tl": "A dalt a l'esquerra",
|
||||
@ -2073,9 +2060,6 @@
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "La mida 'petita' amb el model Jina V2 té un alt cost de RAM i d'inferència. Es recomana el model 'gran' amb una GPU discreta.",
|
||||
"modelSizeIgnoredForProvider": "La mida del model només s'aplica als models de Jina incorporats. Aquest valor s'ignorarà quan s'utilitzi un proveïdor d'incrustació GenAI."
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Autotraquejar requereix al menys una zona. Defineix una zona per aquesta cámera a Mascares/Zones, després usa'l com a requerit a la part inferior."
|
||||
}
|
||||
},
|
||||
"modelSize": {
|
||||
|
||||
@ -193,8 +193,7 @@
|
||||
"gl": "Galego (Galicisch)",
|
||||
"id": "Bahasa Indonesia (Indonesisch)",
|
||||
"hr": "Hrvatski (Kroatisch)",
|
||||
"bs": "Bosnisch",
|
||||
"zhHant": "Traditional Chinese"
|
||||
"bs": "Bosnisch"
|
||||
},
|
||||
"appearance": "Erscheinung",
|
||||
"theme": {
|
||||
|
||||
@ -48,6 +48,5 @@
|
||||
"submittedFrigatePlus": "Bild erfolgreich an Frigate+ gesendet"
|
||||
}
|
||||
},
|
||||
"noPreviewFoundFor": "Keine Vorschau für {{cameraName}} gefunden",
|
||||
"cameraOff": "Kamera ist ausgeschaltet"
|
||||
"noPreviewFoundFor": "Keine Vorschau für {{cameraName}} gefunden"
|
||||
}
|
||||
|
||||
@ -826,7 +826,7 @@
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Format für Zeitstempel",
|
||||
"description": "Gestaltungsoptionen für Zeitstempel, die auf Momentaufnahmen und die Debug-Ansicht angewendet werden.",
|
||||
"description": "Gestaltungsmöglichkeiten für Zeitstempel im Feed, die auf Aufzeichnungen und Momentaufnahmen angewendet werden.",
|
||||
"position": {
|
||||
"label": "Position des Zeitstempels",
|
||||
"description": "Position des Zeitstempels auf dem Bild (tl/tr/bl/br)."
|
||||
|
||||
@ -496,7 +496,7 @@
|
||||
},
|
||||
"default_role": {
|
||||
"label": "Standardrolle",
|
||||
"description": "Standardrolle, die proxy-authentifizierten Benutzern zugewiesen wird, wenn keine Rollenzuordnung vorliegt."
|
||||
"description": "Standardrolle, die proxy-authentifizierten Benutzern zugewiesen wird, wenn keine Rollenzuordnung gilt (Admin oder Betrachter)."
|
||||
},
|
||||
"separator": {
|
||||
"label": "Trennzeichen",
|
||||
|
||||
@ -144,9 +144,7 @@
|
||||
},
|
||||
"camera": {
|
||||
"enable": "Kamera aktivieren",
|
||||
"disable": "Kamera deaktivieren",
|
||||
"turnOn": "Schalte die Kamera ein",
|
||||
"turnOff": "Schalte die Kamera aus"
|
||||
"disable": "Kamera deaktivieren"
|
||||
},
|
||||
"audioDetect": {
|
||||
"enable": "Audioerkennung aktivieren",
|
||||
@ -164,8 +162,7 @@
|
||||
"autotracking": "Autotracking",
|
||||
"audioDetection": "Audioerkennung",
|
||||
"title": "{{camera}} Einstellungen",
|
||||
"transcription": "Audio Transkription",
|
||||
"camera": "Kamera"
|
||||
"transcription": "Audio Transkription"
|
||||
},
|
||||
"history": {
|
||||
"label": "Historisches Filmmaterial zeigen"
|
||||
|
||||
@ -24,9 +24,7 @@
|
||||
"points_one": "{{count}} Punkt",
|
||||
"points_other": "{{count}} Punkte",
|
||||
"undo": "Letzten Schritt rückgängig machen",
|
||||
"reset": "Polygon zurücksetzen",
|
||||
"drawMode": "ziehen",
|
||||
"moveMode": "bewegen"
|
||||
"reset": "Polygon zurücksetzen"
|
||||
},
|
||||
"motionHeatmapLabel": "Bewegungs-Heatmap",
|
||||
"dialog": {
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"enrichments": "Erkennungsfunktionen",
|
||||
"triggers": "Auslöser",
|
||||
"roles": "Rollen",
|
||||
"cameraManagement": "Kamera Verwaltung",
|
||||
"cameraManagement": "Verwaltung",
|
||||
"cameraReview": "Überprüfung",
|
||||
"system": "System",
|
||||
"general": "allgemein",
|
||||
@ -730,8 +730,7 @@
|
||||
"notificationUnavailable": {
|
||||
"title": "Benachrichtigungen nicht verfügbar",
|
||||
"desc": "Web Push Benachrichtigungen erfordern einen sicheren Kontext (<code>https://…</code>). Das ist eine Vorgabe des Browsers. Greife auf Frigate gesichert zu um Benachrichtigungen zu nutzen.",
|
||||
"documentation": "Dokumentation lesen",
|
||||
"descPwa": "Unter iOS sind Web-Push-Benachrichtigungen nur verfügbar, wenn Frigate auf Ihrem Startbildschirm installiert ist. Öffnen Sie das Menü <strong>Teilen</strong>, wählen Sie <strong>Zum Startbildschirm hinzufügen</strong> und öffnen Sie Frigate über das neue Symbol, um dieses Gerät für Benachrichtigungen zu registrieren."
|
||||
"documentation": "Dokumentation lesen"
|
||||
},
|
||||
"cameras": {
|
||||
"desc": "Wähle aus für welche Kameras Benachrichtigungen aktiviert werden sollen.",
|
||||
@ -803,7 +802,7 @@
|
||||
"cameras": "Kameras",
|
||||
"loading": "Lade Model Informationen…",
|
||||
"error": "Model Informationen laden fehlgeschlagen",
|
||||
"availableModels": "Verfügbare Frigate+ Modelle",
|
||||
"availableModels": "Verfügbare Modelle",
|
||||
"loadingAvailableModels": "Lade verfügbare Modelle…",
|
||||
"baseModel": "Basis Model",
|
||||
"title": "Model Informationen",
|
||||
@ -1137,7 +1136,7 @@
|
||||
"brands": {
|
||||
"reolink-rtsp": "Reolink RTSP wird nicht empfohlen. Es wird empfohlen, http in den Kameraeinstellungen zu aktivieren und den Kamera-Assistenten neu zu starten."
|
||||
},
|
||||
"customUrlRtspRequired": "Benutzerdefinierte URLs müssen mit „rtsp://“ oder \"rtsps://\" beginnen. Für Nicht-RTSP-Kamerastreams ist eine manuelle Konfiguration erforderlich."
|
||||
"customUrlRtspRequired": "Benutzerdefinierte URLs müssen mit „rtsp://“ beginnen. Für Nicht-RTSP-Kamerastreams ist eine manuelle Konfiguration erforderlich."
|
||||
},
|
||||
"docs": {
|
||||
"reolink": "https://docs.frigate.video/configuration/camera_specific.html#reolink-cameras"
|
||||
@ -1357,13 +1356,13 @@
|
||||
"selectCamera": "Wähle eine Kamera",
|
||||
"backToSettings": "Zurück zu Kameraeinstellungen",
|
||||
"streams": {
|
||||
"title": "Kamerastatus und Details",
|
||||
"title": "Kameras aktivieren / deaktivieren",
|
||||
"desc": "Deaktiviere eine Kamera vorübergehend, bis Frigate neu gestartet wird. Deaktivierung einer Kamera stoppt die Verarbeitung der Streams dieser Kamera durch Frigate vollständig. Erkennung, Aufzeichnung und Debugging sind dann nicht mehr verfügbar. <br /> <em>Hinweis: Dies deaktiviert nicht die go2rtc restreams.</em>",
|
||||
"enableLabel": "Aktivierte Kameras",
|
||||
"enableDesc": "</em>Eine aktivierte Kamera vorübergehend deaktivieren, bis Frigate neu gestartet wird. Durch das Deaktivieren einer Kamera wird die Verarbeitung der Streams dieser Kamera durch Frigate vollständig unterbrochen. Erkennung, Aufzeichnung und Fehlerbehebung stehen dann nicht mehr zur Verfügung.<br /><em> Hinweis: go2rtc-Restreams werden dadurch nicht deaktiviert.</em>",
|
||||
"disableLabel": "Deaktivierte Kameras",
|
||||
"disableDesc": "Aktivieren Sie eine Kamera, die derzeit in der Benutzeroberfläche nicht sichtbar und in der Konfiguration deaktiviert ist. Nach der Aktivierung ist ein Neustart von Frigate erforderlich.",
|
||||
"enableSuccess": "{{cameraName}} wurde aktiviert. Starte Frigate neu, um die Änderung zu übernehmen.",
|
||||
"enableSuccess": "{{cameraName}} wurde in der Konfiguration aktiviert. Starte Frigate neu, um die Änderungen zu übernehmen.",
|
||||
"friendlyName": {
|
||||
"edit": "Anzeigenamen der Kamera bearbeiten",
|
||||
"title": "Anzeigenamen bearbeiten",
|
||||
@ -1382,16 +1381,7 @@
|
||||
"webuiUrlLabel": "URL der Web-Benutzeroberfläche",
|
||||
"webuiUrlHelp": "URL, um die Web-Benutzeroberfläche der Kamera direkt aus der Debug-Ansicht aufzurufen. Lassen Sie das Feld leer, um den Link zu deaktivieren.",
|
||||
"webuiUrlInvalid": "Es muss sich um eine gültige URL handeln (z. B. https://example.com)."
|
||||
},
|
||||
"label": "Kamerazustand",
|
||||
"description": "Legen Sie den Betriebszustand für jede Kamera fest.<br /><br /><strong>Ein</strong>: Streams werden normal verarbeitet.<br /><strong>Aus</strong>: Die Verarbeitung wird vorübergehend angehalten. Diese Einstellung bleibt bei einem Neustart von Frigate nicht erhalten.<br /><strong>Deaktiviert</strong>: Die Verarbeitung wird beendet und die Änderung in Ihrer Konfiguration gespeichert. Um eine deaktivierte Kamera wieder zu aktivieren, ist ein Neustart erforderlich.<br /><br /><em>Hinweis: Die Deaktivierung hat keine Auswirkungen auf go2rtc-Restreams.</em><br /><br />Ziehen Sie den Griff, um die Reihenfolge der aktiven Kameras in der Benutzeroberfläche anzupassen, einschließlich des Live-Dashboards und der Dropdown-Menüs zur Kameraauswahl.",
|
||||
"disabledSubheading": "In der Konfiguration deaktiviert",
|
||||
"status": {
|
||||
"on": "Eingeschaltet",
|
||||
"off": "Ausgeschaltet",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"disableSuccess": "{{cameraName}} wurde deaktiviert und in der Konfiguration gespeichert."
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Kamera hinzufügen",
|
||||
@ -1437,12 +1427,10 @@
|
||||
"profiles": {
|
||||
"title": "Profilkameraumschaltungen",
|
||||
"selectLabel": "Profil auswählen",
|
||||
"description": "Legen Sie fest, welche Kameras bei der Aktivierung eines Profils ein- oder ausgeschaltet werden. Kameras, die auf „Übernehmen“ eingestellt sind, behalten ihren Standardzustand bei.",
|
||||
"description": "Legen Sie fest, welche Kameras bei der Aktivierung eines Profils aktiviert oder deaktiviert werden sollen. Kameras, für die „Übernehmen“ eingestellt ist, behalten ihren ursprünglichen Aktivierungsstatus bei.",
|
||||
"inherit": "Erben",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"on": "Eingeschaltet",
|
||||
"off": "Ausgeschaltet"
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"cameraType": {
|
||||
"title": "Kamera Art",
|
||||
@ -1452,92 +1440,7 @@
|
||||
"dedicatedLpr": "Spezielles LPR-System",
|
||||
"saveSuccess": "Der Kameratyp für {{cameraName}} wurde aktualisiert. Starte Frigate neu, um die Änderungen zu übernehmen."
|
||||
},
|
||||
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, steuern Sie den Status jeder einzelnen Kamera und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus.",
|
||||
"clone": {
|
||||
"sectionTitle": "Einstellungen klonen",
|
||||
"sectionDescription": "Konfiguration von einer Kamera auf eine andere oder eine neue Kamera kopieren.",
|
||||
"button": "Einstellungen klonen",
|
||||
"title": "Kameraeinstellungen kopieren",
|
||||
"description": "Kopieren Sie die Konfiguration einer Kamera auf eine oder mehrere andere Kameras oder auf eine neue Kamera. Die Identitätsdaten (Name, Anzeigename, URL der Web-Benutzeroberfläche, Anzeigereihenfolge) werden dabei nicht kopiert.",
|
||||
"source": {
|
||||
"label": "Quellkamera",
|
||||
"placeholder": "Wählen Sie eine Quellkamera aus",
|
||||
"required": "Wählen Sie eine Quellkamera aus"
|
||||
},
|
||||
"target": {
|
||||
"legend": "Ziel",
|
||||
"newRadio": "Neue Kamera",
|
||||
"newNameLabel": "Kamera Name",
|
||||
"newNamePlaceholder": "z. B. back_door oder Back Door",
|
||||
"newNameRequired": "Kamera Name ist erforderlich",
|
||||
"newNameInvalid": "ungültiger Kamera Name",
|
||||
"newNameCollision": "Eine Kamera mit diesem Namen gibt es bereits",
|
||||
"newStreamsForced": "Streams werden bei einer neuen Kamera immer kopiert.",
|
||||
"existingCamerasRadio": "Vorhandene Kameras",
|
||||
"allCameras": "Alle Kameras",
|
||||
"existingPlaceholder": "Wählen Sie mindestens eine Kamera aus",
|
||||
"existingDisabled": "Es gibt keine weiteren Kameras, auf die kopiert werden kann"
|
||||
},
|
||||
"categories": {
|
||||
"legend": "Zu klonende Einstellungen",
|
||||
"description": "Wählen Sie aus, welche Einstellungen von der Quellkamera kopiert werden sollen.",
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectNone": "Keine auswählen",
|
||||
"resetDefaults": "Auf Standardwerte zurücksetzen",
|
||||
"general": "Allgemeines",
|
||||
"spatial": "Räumliche Rahmenbedingungen",
|
||||
"streams": "Streams",
|
||||
"spatialWarningTitle": "Auflösungsdiskrepanz",
|
||||
"spatialWarning": "Die Quellkamera {{srcCamera}} hat eine andere Auflösung ({{srcWidth}}×{{srcHeight}}) als: {{cameras}}. Die Polygone sind möglicherweise nicht auf diese Kameras ausgerichtet. Diese Standardeinstellungen sind deaktiviert; aktivieren Sie sie, um die Daten unverändert zu kopieren.",
|
||||
"restartHint": "Neustart erforderlich",
|
||||
"items": {
|
||||
"record": "Aufnahme",
|
||||
"snapshots": "Momentaufnahmen",
|
||||
"motion": "Bewegungserkennung",
|
||||
"objects": "Objekte",
|
||||
"audio": "Tonerkennung",
|
||||
"audio_transcription": "Audio-Transkription",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"birdseye": "Birdseye",
|
||||
"mqtt": "MQTT",
|
||||
"timestamp_style": "Format für Zeitstempel",
|
||||
"onvif": "ONVIF",
|
||||
"lpr": "Kennzeichenerkennung",
|
||||
"face_recognition": "Gesichtserkennung",
|
||||
"semantic_search": "Semantische Suche",
|
||||
"genai": "Generative AI",
|
||||
"type": "Kameratyp (Standard / speziell für Kennzeichenerkennung)",
|
||||
"profiles": "Profile",
|
||||
"detect": "Abmessungen ermitteln",
|
||||
"zones": "Zonen",
|
||||
"motion_mask": "Bewegungsmaske",
|
||||
"object_masks": "Objektmaske",
|
||||
"ffmpeg_live": "Stream-URLs und Rollen",
|
||||
"review": "Rezension"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"restartNeeded": "Für einige Änderungen ist ein Neustart erforderlich.",
|
||||
"liveOnly": "Alle Änderungen werden sofort wirksam, ohne dass ein Neustart erforderlich ist.",
|
||||
"submit": "Klon",
|
||||
"submitting": "Klonen…",
|
||||
"changeCount_one": "Die Änderung von {{count}} wird übernommen",
|
||||
"changeCount_other": "Die Änderungen von {{count}} werden übernommen"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Einstellungen wurden auf {{cameraName}} kopiert",
|
||||
"successWithRestart": "Die Einstellungen wurden auf {{cameraName}} kopiert. Starte Frigate neu, um alle Änderungen zu übernehmen.",
|
||||
"successMulti_one": "Einstellungen wurden auf {{count}} Kamera kopiert",
|
||||
"successMulti_other": "Einstellungen wurden auf {{count}} Kameras kopiert",
|
||||
"successMultiWithRestart_one": "Die Einstellungen wurden auf die Kamera {{count}} kopiert. Starte Frigate neu, um alle Änderungen zu übernehmen.",
|
||||
"successMultiWithRestart_other": "Die Einstellungen wurden auf {{count}} Kameras kopiert. Starten Sie Frigate neu, um alle Änderungen zu übernehmen.",
|
||||
"partialFailure": "{{successCount}} Abschnitte wurden angewendet; '{{failedSection}}' ist fehlgeschlagen: {{errorMessage}}",
|
||||
"partialFailureMulti": "{{successCount}} Kamera(s) wurden kopiert; bei {{failed}} ist ein Fehler aufgetreten: {{errorMessage}}",
|
||||
"newCameraPartialFailure": "Die Kamera {{cameraName}} wurde erstellt, einige Einstellungen konnten jedoch nicht kopiert werden: {{errorMessage}}",
|
||||
"sourceMissing": "Die Quellkamera existiert nicht mehr",
|
||||
"submitError": "Das Klonen der Kamera ist fehlgeschlagen: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
"description": "Fügen Sie Kameras hinzu, bearbeiten und löschen Sie sie, legen Sie fest, welche Kameras aktiviert sind, und konfigurieren Sie profil- und kameratypabhängige Übersteuerungen. Um Streams, Erkennung, Bewegung und andere kameraspezifische Einstellungen zu konfigurieren, wählen Sie den entsprechenden Abschnitt unter „Kamerakonfiguration“ aus."
|
||||
},
|
||||
"cameraReview": {
|
||||
"title": "Kamera-Einstellungen überprüfen",
|
||||
@ -1886,8 +1789,8 @@
|
||||
"platePlaceholder": "Kennzeichen oder regulärer Ausdruck"
|
||||
},
|
||||
"genaiModel": {
|
||||
"placeholder": "Modell auswählen oder eingeben…",
|
||||
"search": "Modell suchen oder eingeben…",
|
||||
"placeholder": "Modell auswählen…",
|
||||
"search": "Modell suchen…",
|
||||
"noModels": "Keine Modelle verfügbar",
|
||||
"available": "Verfügbare Modelle",
|
||||
"useCustom": "Verwende „{{value}}“",
|
||||
@ -1897,28 +1800,6 @@
|
||||
},
|
||||
"semanticSearchModelSize": {
|
||||
"notApplicable": "Gilt nicht für GenAI-Anbieter"
|
||||
},
|
||||
"liveStreams": {
|
||||
"streamNameLabel": "Streamname",
|
||||
"streamNamePlaceholder": "z. B. Haupt-HD-Stream",
|
||||
"go2rtcStreamLabel": "go2rtc stream",
|
||||
"go2rtcStreamPlaceholder": "Wählen Sie einen go2rtc-Stream aus",
|
||||
"go2rtcStreamSearch": "Suchen Sie nach einem Streamnamen oder geben Sie ihn ein…",
|
||||
"noGo2rtcStreams": "Es sind keine go2rtc-Streams konfiguriert",
|
||||
"availableStreams": "Verfügbare Streams",
|
||||
"useCustom": "Verwende „{{value}}“",
|
||||
"addStream": "Stream hinzufügen"
|
||||
},
|
||||
"ptzPresets": {
|
||||
"placeholder": "Wählen Sie eine Voreinstellung aus oder geben Sie eine ein...",
|
||||
"search": "Suchen oder eine Voreinstellung eingeben...",
|
||||
"noPresets": "Es sind keine Voreinstellungen verfügbar",
|
||||
"available": "Kamera-Voreinstellungen",
|
||||
"useCustom": "Verwende „{{value}}“"
|
||||
},
|
||||
"defaultRole": {
|
||||
"admin": "Admin",
|
||||
"viewer": "Betrachter"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
@ -2041,7 +1922,7 @@
|
||||
"audioMp3": "Transcode zu MP3",
|
||||
"audioExclude": "Ausschließen",
|
||||
"hardwareNone": "Keine Hardwarebeschleunigung",
|
||||
"hardwareAuto": "Automatisch (empfohlen)",
|
||||
"hardwareAuto": "Automatische Hardwarebeschleunigung",
|
||||
"hardwareVaapi": "VAAPI",
|
||||
"hardwareCuda": "CUDA",
|
||||
"hardwareV4l2m2m": "V4L2 M2M",
|
||||
@ -2051,8 +1932,7 @@
|
||||
"addAudioCodec": "Audio-Codec hinzufügen",
|
||||
"removeCodec": "Codec entfernen"
|
||||
},
|
||||
"streamNumber": "Stream {{index}}",
|
||||
"sourceNumber": "Quelle {{index}}"
|
||||
"streamNumber": "Stream {{index}}"
|
||||
},
|
||||
"onvif": {
|
||||
"profileAuto": "Auto",
|
||||
@ -2112,9 +1992,6 @@
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "Die „kleine“ Variante des Jina V2-Modells verursacht hohe RAM- und Inferenzkosten. Es wird das „große“ Modell mit einer dedizierten GPU empfohlen."
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Für die automatische Verfolgung ist mindestens eine Zone erforderlich. Definieren Sie unter „Masken / Zonen“ eine Zone für diese Kamera und legen Sie diese anschließend unten als erforderliche Zone fest."
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
|
||||
@ -682,7 +682,7 @@
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Timestamp style",
|
||||
"description": "Styling options for timestamps applied to snapshots and Debug view.",
|
||||
"description": "Styling options for in-feed timestamps applied to recordings and snapshots.",
|
||||
"position": {
|
||||
"label": "Timestamp position",
|
||||
"description": "Position of the timestamp on the image (tl/tr/bl/br)."
|
||||
|
||||
@ -212,7 +212,7 @@
|
||||
},
|
||||
"default_role": {
|
||||
"label": "Default role",
|
||||
"description": "Default role assigned to proxy-authenticated users when no role mapping applies."
|
||||
"description": "Default role assigned to proxy-authenticated users when no role mapping applies (admin or viewer)."
|
||||
},
|
||||
"separator": {
|
||||
"label": "Separator character",
|
||||
@ -270,6 +270,14 @@
|
||||
"label": "Time format",
|
||||
"description": "Time format to use in the UI (browser, 12hour, or 24hour)."
|
||||
},
|
||||
"date_style": {
|
||||
"label": "Date style",
|
||||
"description": "Date style to use in the UI (full, long, medium, short)."
|
||||
},
|
||||
"time_style": {
|
||||
"label": "Time style",
|
||||
"description": "Time style to use in the UI (full, long, medium, short)."
|
||||
},
|
||||
"unit_system": {
|
||||
"label": "Unit system",
|
||||
"description": "Unit system for display (metric or imperial) used in the UI and MQTT."
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
"searchCancelled": "Search cancelled",
|
||||
"cancelSearch": "Cancel",
|
||||
"searching": "Search in progress.",
|
||||
"scanning": "Scanning {{time}}",
|
||||
"searchComplete": "Search complete",
|
||||
"noResultsYet": "Run a search to find motion changes in the selected region",
|
||||
"noChangesFound": "No pixel changes detected in the selected region",
|
||||
@ -43,11 +42,13 @@
|
||||
"settings": {
|
||||
"title": "Search Settings",
|
||||
"parallelMode": "Parallel mode",
|
||||
"parallelModeDesc": "Scan multiple recording ranges at the same time (faster; uses more decoding resources)",
|
||||
"parallelModeDesc": "Scan multiple recording segments at the same time (faster, but significantly more CPU intensive)",
|
||||
"threshold": "Sensitivity Threshold",
|
||||
"thresholdDesc": "Lower values detect smaller changes (1-255)",
|
||||
"minArea": "Minimum Change Area",
|
||||
"minAreaDesc": "Minimum size of a single moving region, as a percentage of the region of interest",
|
||||
"minAreaDesc": "Minimum percentage of the region of interest that must change to be considered significant",
|
||||
"frameSkip": "Frame Skip",
|
||||
"frameSkipDesc": "Process every Nth frame. Set this to your camera's frame rate to process one frame per second (e.g. 5 for a 5 FPS camera, 30 for a 30 FPS camera). Higher values will be faster, but may miss short motion events.",
|
||||
"maxResults": "Maximum Results",
|
||||
"maxResultsDesc": "Stop after this many matching timestamps"
|
||||
},
|
||||
@ -71,8 +72,6 @@
|
||||
"framesDecoded": "Frames decoded",
|
||||
"wallTime": "Search time",
|
||||
"segmentErrors": "Segment errors",
|
||||
"seconds": "{{seconds}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"scanSummary": "{{segments}} segments · {{time}}"
|
||||
"seconds": "{{seconds}}s"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1154,8 +1154,7 @@
|
||||
},
|
||||
"notificationUnavailable": {
|
||||
"title": "Notifications Unavailable",
|
||||
"desc": "Web push notifications require a secure context (<code>https://…</code>). This is a browser limitation. Access Frigate securely to use notifications.",
|
||||
"descPwa": "On iOS, web push notifications are only available when Frigate is installed to your Home Screen. Open the <strong>Share</strong> menu, choose <strong>Add to Home Screen</strong>, then open Frigate from the new icon to register this device for notifications."
|
||||
"desc": "Web push notifications require a secure context (<code>https://…</code>). This is a browser limitation. Access Frigate securely to use notifications."
|
||||
},
|
||||
"globalSettings": {
|
||||
"title": "Global Settings",
|
||||
@ -1675,17 +1674,6 @@
|
||||
"refresh": "Refresh models",
|
||||
"probeFailed": "Failed to probe models",
|
||||
"fetchedModels": "Successfully fetched model list"
|
||||
},
|
||||
"ptzPresets": {
|
||||
"placeholder": "Select or enter a preset...",
|
||||
"search": "Search or enter a preset...",
|
||||
"noPresets": "No presets available",
|
||||
"available": "Camera presets",
|
||||
"useCustom": "Use \"{{value}}\""
|
||||
},
|
||||
"defaultRole": {
|
||||
"admin": "Admin",
|
||||
"viewer": "Viewer"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
@ -1775,7 +1763,7 @@
|
||||
"addStream": "Add stream",
|
||||
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
|
||||
"addUrl": "Add URL",
|
||||
"sourceNumber": "Source {{index}}",
|
||||
"streamNumber": "Stream {{index}}",
|
||||
"streamName": "Stream name",
|
||||
"streamNamePlaceholder": "e.g., front_door",
|
||||
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
|
||||
@ -1852,6 +1840,12 @@
|
||||
"12hour": "12 hour",
|
||||
"24hour": "24 hour"
|
||||
},
|
||||
"TimeOrDateStyle": {
|
||||
"full": "Full",
|
||||
"long": "Long",
|
||||
"medium": "Medium",
|
||||
"short": "Short"
|
||||
},
|
||||
"unitSystem": {
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial"
|
||||
@ -1934,9 +1928,6 @@
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Autotracking requires at least one zone. Define a zone for this camera in Masks / Zones, then set it as a required zone below."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -896,7 +896,7 @@
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Estilo de marca de tiempo",
|
||||
"description": "Opciones de estilo para las marcas de tiempo aplicadas a las instantáneas y a la vista de depuración.",
|
||||
"description": "Opciones de estilo para marcas de tiempo integradas aplicadas a grabaciones e instantáneas.",
|
||||
"position": {
|
||||
"label": "Posición de marca de tiempo",
|
||||
"description": "Posición de la marca de tiempo en la imagen (tl/tr/bl/br)."
|
||||
|
||||
@ -610,7 +610,7 @@
|
||||
"description": "Carácter usado para separar varios valores proporcionados en las cabeceras del proxy."
|
||||
},
|
||||
"default_role": {
|
||||
"description": "Rol predeterminado asignado a los usuarios autenticados mediante proxy cuando no se aplica ninguna asignación de roles.",
|
||||
"description": "Rol predeterminado asignado a los usuarios autenticados por proxy cuando no se aplica ningún mapeo de roles (administrador o espectador).",
|
||||
"label": "Rol predeterminado"
|
||||
},
|
||||
"description": "Configuración para integrar Frigate detrás de un proxy inverso que transmite encabezados de usuario autenticados.",
|
||||
|
||||
@ -26,9 +26,7 @@
|
||||
"points_many": "{{count}} puntos",
|
||||
"points_other": "{{count}} puntos",
|
||||
"undo": "Deshacer el último punto",
|
||||
"reset": "Restablecer polígono",
|
||||
"drawMode": "Dibujar",
|
||||
"moveMode": "Mover"
|
||||
"reset": "Restablecer polígono"
|
||||
},
|
||||
"motionHeatmapLabel": "Mapa de calor de movimiento",
|
||||
"dialog": {
|
||||
|
||||
@ -727,8 +727,7 @@
|
||||
"notificationUnavailable": {
|
||||
"title": "Notificaciones no disponibles",
|
||||
"documentation": "Leer la documentación",
|
||||
"desc": "Las notificaciones push web requieren un contexto seguro (<code>https://…</code>). Esto es una limitación del navegador. Accede a Frigate de forma segura para usar las notificaciones.",
|
||||
"descPwa": "En iOS, las notificaciones push web solo están disponibles cuando Frigate está instalado en la pantalla de inicio. Abre el menú <strong>Compartir</strong>, selecciona <strong>Añadir a la pantalla de inicio</strong> y, a continuación, abre Frigate desde el nuevo icono para registrar este dispositivo para las notificaciones."
|
||||
"desc": "Las notificaciones push web requieren un contexto seguro (<code>https://…</code>). Esto es una limitación del navegador. Accede a Frigate de forma segura para usar las notificaciones."
|
||||
},
|
||||
"globalSettings": {
|
||||
"title": "Configuración global",
|
||||
@ -1815,17 +1814,6 @@
|
||||
"availableStreams": "Flujos disponibles",
|
||||
"useCustom": "Usar “{{value}}”",
|
||||
"addStream": "Añadir flujo"
|
||||
},
|
||||
"ptzPresets": {
|
||||
"placeholder": "Selecciona o introduce un preajuste…",
|
||||
"search": "Busca o introduce un preajuste…",
|
||||
"noPresets": "No hay preajustes disponibles",
|
||||
"available": "Preajustes de cámara",
|
||||
"useCustom": "Usar “{{value}}”"
|
||||
},
|
||||
"defaultRole": {
|
||||
"admin": "Admin",
|
||||
"viewer": "Visualizador"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
@ -1958,8 +1946,7 @@
|
||||
"addAudioCodec": "Añadir códec de audio",
|
||||
"removeCodec": "Eliminar códec"
|
||||
},
|
||||
"streamNumber": "Flujo {{index}}",
|
||||
"sourceNumber": "Origen {{index}}"
|
||||
"streamNumber": "Flujo {{index}}"
|
||||
},
|
||||
"configMessages": {
|
||||
"birdseye": {
|
||||
@ -2009,9 +1996,6 @@
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "El tamaño 'small' con el modelo Jina V2 tiene un alto consumo de RAM y coste de inferencia. Se recomienda el modelo 'large' con una GPU dedicada.",
|
||||
"modelSizeIgnoredForProvider": "El tamaño del modelo solo se aplica a los modelos Jina integrados. Este valor se ignorará al usar un proveedor de embeddings GenAI."
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "El seguimiento automático requiere al menos una zona. Define una zona para esta cámara en Máscaras / Zonas y, a continuación, establécela como zona obligatoria a continuación."
|
||||
}
|
||||
},
|
||||
"resetToDefaultDescription": "Esto restablecerá todos los ajustes de esta sección a sus valores predeterminados. Esta acción no se puede deshacer.",
|
||||
|
||||
@ -1377,54 +1377,6 @@
|
||||
"inherit": "Hériter",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"clone": {
|
||||
"target": {
|
||||
"newNameLabel": "Nom de la caméra",
|
||||
"newNamePlaceholder": "p.ex., porte_arriere ou Porte arrière",
|
||||
"newNameRequired": "Le nom de la caméra est requis",
|
||||
"newNameInvalid": "Nom de caméra invalide",
|
||||
"newNameCollision": "Une caméra avec ce nom existe déjà",
|
||||
"newStreamsForced": "Les flux sont toujours copiés pour une nouvelle caméra.",
|
||||
"existingCamerasRadio": "Caméras existantes",
|
||||
"allCameras": "Toutes les caméras",
|
||||
"existingPlaceholder": "Sélectionnez au moins une caméra",
|
||||
"existingDisabled": "Aucune autre caméra à copier vers"
|
||||
},
|
||||
"categories": {
|
||||
"legend": "Paramètres à cloner",
|
||||
"description": "Choisissez quels paramètres copier depuis la caméra source.",
|
||||
"selectAll": "Sélectionner tout",
|
||||
"selectNone": "Sélectionner aucun",
|
||||
"resetDefaults": "Rétablir à la configuration d'usine",
|
||||
"general": "Général",
|
||||
"spatial": "Paramètres spatiaux",
|
||||
"streams": "Flux",
|
||||
"spatialWarning": "La résolution détectée ({{srcWidth}}x{{srcHeight}}) de la caméra source {{srcCamera}} diffère de : {{cameras}}. Les polygones peuvent ne pas être alignés sur ces caméras. Ces paramètres sont désactivés ; activer pour copier tel quel.",
|
||||
"restartHint": "Redémarrage requis",
|
||||
"items": {
|
||||
"record": "En cours d'enregistrement",
|
||||
"objects": "Objets",
|
||||
"audio": "Détection audio",
|
||||
"audio_transcription": "Transcription audio",
|
||||
"notifications": "Notifications",
|
||||
"mqtt": "MQTT",
|
||||
"onvif": "ONVIF",
|
||||
"face_recognition": "Reconnaissance faciale",
|
||||
"semantic_search": "Recherche sémantique"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"submit": "Cloner",
|
||||
"submitting": "Clonage…"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Paramètres copiés vers {{cameraName}}",
|
||||
"successWithRestart": "Paramètres copiés vers {{cameraName}}. Redémarrez Frigate afin d'appliquer tous les changements.",
|
||||
"successMulti_one": "Paramètres copiés vers {{count}} caméra",
|
||||
"successMulti_many": "Paramètres copiés vers {{count}} caméras",
|
||||
"successMulti_other": "Paramètres copiés vers {{count}} caméras"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraReview": {
|
||||
|
||||
@ -718,7 +718,7 @@
|
||||
"label": "时间戳效果",
|
||||
"description": "时间戳文本的视觉效果(none、solid、shadow)。"
|
||||
},
|
||||
"description": "快照与调试视图的时间戳样式设置。"
|
||||
"description": "应用于录像和快照的实时监控流中时间戳的样式选项。"
|
||||
},
|
||||
"semantic_search": {
|
||||
"label": "语义搜索",
|
||||
|
||||
@ -496,7 +496,7 @@
|
||||
},
|
||||
"default_role": {
|
||||
"label": "默认权限组",
|
||||
"description": "当没有权限组映射适用时分配给代理认证用户的默认权限组。"
|
||||
"description": "当没有权限组映射适用时分配给代理认证用户的默认权限组(admin 或 viewer)。"
|
||||
},
|
||||
"separator": {
|
||||
"label": "分隔符",
|
||||
|
||||
@ -22,9 +22,7 @@
|
||||
"polygonControls": {
|
||||
"points_other": "{{count}} 个点位",
|
||||
"undo": "撤销上一个点位",
|
||||
"reset": "重置多边形",
|
||||
"drawMode": "绘制",
|
||||
"moveMode": "移动"
|
||||
"reset": "重置多边形"
|
||||
},
|
||||
"motionHeatmapLabel": "画面变动热力图",
|
||||
"dialog": {
|
||||
|
||||
@ -726,8 +726,7 @@
|
||||
"notificationUnavailable": {
|
||||
"title": "通知功能不可用",
|
||||
"desc": "网页推送通知需要安全连接(<code>https://…</code>)。这是浏览器的限制。请通过安全方式访问 Frigate 以使用通知功能。",
|
||||
"documentation": "阅读文档",
|
||||
"descPwa": "在 iOS 设备上,只有将 Frigate 安装到主屏幕后,才能使用网页推送通知。请打开<strong>分享</strong>菜单,选择<strong>添加到主屏幕</strong>,然后从新生成的图标打开 Frigate,即可注册此设备以接收通知。"
|
||||
"documentation": "阅读文档"
|
||||
},
|
||||
"email": {
|
||||
"title": "电子邮箱",
|
||||
@ -858,7 +857,7 @@
|
||||
"desc": "将使用 <strong>大</strong>模型。该选项使用了完整的 Jina 模型,条件允许的情况下将自动使用 GPU 运行。"
|
||||
}
|
||||
},
|
||||
"title": "语义搜索",
|
||||
"title": "分类搜索",
|
||||
"desc": "Frigate 中的语义搜索功能将能够让你通过图片、用户自定义的文本描述,或自动生成的文本描述等方式在核查项目中查找目标/物体。",
|
||||
"readTheDocumentation": "阅读文档"
|
||||
},
|
||||
@ -1134,7 +1133,7 @@
|
||||
"brands": {
|
||||
"reolink-rtsp": "不建议使用萤石 RTSP 协议。建议在摄像头设置中启用 HTTP 协议,并重新运行摄像头添加向导。"
|
||||
},
|
||||
"customUrlRtspRequired": "自定义 URL 必须以“rtsp://”或“rtsps://”开头;对于非 RTSP 协议的摄像头流,需手动添加至配置文件。"
|
||||
"customUrlRtspRequired": "自定义 URL 必须以“rtsp://”开头;对于非 RTSP 协议的摄像头流,需手动添加至配置文件。"
|
||||
},
|
||||
"docs": {
|
||||
"reolink": "https://docs.frigate-cn.video/configuration/camera_specific.html#reolink-cameras"
|
||||
@ -1457,89 +1456,7 @@
|
||||
"dedicatedLpr": "车牌识别专用",
|
||||
"saveSuccess": "已更新 {{cameraName}} 的摄像头类型,请重启 Frigate 以使更改生效。"
|
||||
},
|
||||
"description": "添加、编辑和删除摄像头,控制每个摄像头的状态,并配置每个配置文件和摄像头类型的覆盖设置。要配置视频流、检测、画面变动和其他特定于摄像头的设置,请在“摄像头配置”下选择相关功能。",
|
||||
"clone": {
|
||||
"sectionTitle": "复制设置",
|
||||
"sectionDescription": "将摄像头的配置复制到另一台摄像头或新摄像头上。",
|
||||
"button": "复制设置",
|
||||
"title": "复制摄像头设置",
|
||||
"description": "将摄像头的配置复制到其他一个或多个摄像头,或者一个新摄像头上。但摄像头标识(包括名称、别名、Web UI 网址、显示顺序)不会被复制。",
|
||||
"source": {
|
||||
"label": "源摄像头",
|
||||
"placeholder": "选择源摄像头",
|
||||
"required": "选择源摄像头"
|
||||
},
|
||||
"target": {
|
||||
"legend": "目标",
|
||||
"newRadio": "新摄像头",
|
||||
"newNameLabel": "摄像头名称",
|
||||
"newNamePlaceholder": "例如:后门",
|
||||
"newNameRequired": "摄像头名称为必填项",
|
||||
"newNameInvalid": "摄像头名称无效",
|
||||
"newNameCollision": "已存在同名摄像头",
|
||||
"newStreamsForced": "在创建新摄像头时,始终会复制视频流配置。",
|
||||
"existingCamerasRadio": "现有摄像头",
|
||||
"allCameras": "所有摄像头",
|
||||
"existingPlaceholder": "至少选择一个摄像头",
|
||||
"existingDisabled": "没有其他摄像头可供复制"
|
||||
},
|
||||
"categories": {
|
||||
"legend": "要复制的设置",
|
||||
"description": "选择要从源摄像头复制哪些设置。",
|
||||
"selectAll": "选择所有",
|
||||
"selectNone": "取消全选",
|
||||
"resetDefaults": "恢复默认设置",
|
||||
"general": "常规",
|
||||
"spatial": "空间设置",
|
||||
"streams": "视频流",
|
||||
"spatialWarningTitle": "分辨率不一致",
|
||||
"spatialWarning": "源摄像头 {{srcCamera}} 的检测分辨率 ({{srcWidth}}×{{srcHeight}}) 与以下摄像头存在差异:{{cameras}}。在这些摄像头上,多边形(检测区域)可能无法准确对齐。这些默认选项当前处于关闭状态;如果启用,将按原样进行复制。",
|
||||
"restartHint": "需要重启",
|
||||
"items": {
|
||||
"record": "录制",
|
||||
"snapshots": "快照",
|
||||
"review": "核查",
|
||||
"motion": "画面变动检测",
|
||||
"objects": "目标",
|
||||
"audio": "音频检测",
|
||||
"audio_transcription": "音频转录",
|
||||
"notifications": "通知",
|
||||
"birdseye": "鸟瞰图",
|
||||
"mqtt": "MQTT",
|
||||
"timestamp_style": "时间戳样式",
|
||||
"onvif": "ONVIF",
|
||||
"lpr": "车牌识别",
|
||||
"face_recognition": "人脸识别",
|
||||
"semantic_search": "语义搜索",
|
||||
"genai": "生成式 AI",
|
||||
"type": "摄像头类型(通用或车牌识别专用)",
|
||||
"profiles": "配置模板",
|
||||
"detect": "检测维度",
|
||||
"zones": "区域",
|
||||
"motion_mask": "画面变动遮罩",
|
||||
"object_masks": "目标遮罩",
|
||||
"ffmpeg_live": "视频流地址和功能"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"changeCount_other": "将应用 {{count}} 项更改",
|
||||
"restartNeeded": "部分更改需要重启才能生效。",
|
||||
"liveOnly": "所有更改将立即生效,无需重启。",
|
||||
"submit": "复制",
|
||||
"submitting": "复制中…"
|
||||
},
|
||||
"toast": {
|
||||
"success": "设置已复制到 {{cameraName}}",
|
||||
"successWithRestart": "设置已复制到 {{cameraName}}。请重启 Frigate 以应用所有更改。",
|
||||
"successMulti_other": "设置已复制到 {{count}} 个摄像头",
|
||||
"successMultiWithRestart_other": "设置已复制到 {{count}} 个摄像头。请重启 Frigate 以应用所有更改。",
|
||||
"partialFailure": "{{successCount}} 个部分已应用;'{{failedSection}}' 失败:{{errorMessage}}",
|
||||
"partialFailureMulti": "已复制到 {{successCount}} 个摄像头;{{failed}} 个失败:{{errorMessage}}",
|
||||
"newCameraPartialFailure": "摄像头 {{cameraName}} 已创建,但部分设置复制失败:{{errorMessage}}",
|
||||
"sourceMissing": "源摄像头已不存在",
|
||||
"submitError": "复制摄像头失败:{{errorMessage}}"
|
||||
}
|
||||
}
|
||||
"description": "添加、编辑和删除摄像头,控制每个摄像头的状态,并配置每个配置文件和摄像头类型的覆盖设置。要配置视频流、检测、画面变动和其他特定于摄像头的设置,请在“摄像头配置”下选择相关功能。"
|
||||
},
|
||||
"cameraReview": {
|
||||
"title": "摄像头核查设置",
|
||||
@ -1820,17 +1737,6 @@
|
||||
"availableStreams": "可用的视频流",
|
||||
"useCustom": "使用“{{value}}”",
|
||||
"addStream": "添加视频流"
|
||||
},
|
||||
"ptzPresets": {
|
||||
"placeholder": "选择或输入预设…",
|
||||
"search": "搜索或输入预设…",
|
||||
"noPresets": "没有可用的预设",
|
||||
"available": "摄像头预设",
|
||||
"useCustom": "使用 “{{value}}”"
|
||||
},
|
||||
"defaultRole": {
|
||||
"admin": "管理员",
|
||||
"viewer": "成员"
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
@ -2042,8 +1948,7 @@
|
||||
"addAudioCodec": "添加音频编码器",
|
||||
"removeCodec": "移除编码器"
|
||||
},
|
||||
"streamNumber": "视频流 {{index}}",
|
||||
"sourceNumber": "源 {{index}}"
|
||||
"streamNumber": "视频流 {{index}}"
|
||||
},
|
||||
"onvif": {
|
||||
"profileAuto": "自动",
|
||||
@ -2104,9 +2009,6 @@
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "Jina V2 的大型模型版本内存占用与推理开销较高,建议搭配独立显卡使用大型模型。",
|
||||
"modelSizeIgnoredForProvider": "模型大小仅适用于内置的 Jina 模型。当使用生成式 AI 作为嵌入提供者时,此值将被忽略。"
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "自动追踪至少需要一个区域。请先在“遮罩 / 区域”中为此摄像头定义一个区域,然后在下方将其设置为必需区域。"
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
|
||||
@ -51,7 +51,6 @@ export default function Step2StateArea({
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverContainerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const rectRef = useRef<Konva.Rect>(null);
|
||||
@ -225,7 +224,7 @@ export default function Step2StateArea({
|
||||
const canContinue = cameraAreas.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={popoverContainerRef} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4 overflow-hidden",
|
||||
@ -256,7 +255,6 @@ export default function Step2StateArea({
|
||||
className="scrollbar-container w-64 border bg-background p-3 shadow-lg"
|
||||
align="start"
|
||||
sideOffset={5}
|
||||
container={popoverContainerRef.current}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@ -25,24 +25,6 @@ const onvif: SectionConfigOverrides = {
|
||||
advancedFields: ["tls_insecure", "ignore_time_mismatch"],
|
||||
overrideFields: [],
|
||||
restartRequired: ["autotracking.calibrate_on_startup"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "autotracking-no-zones",
|
||||
field: "autotracking.required_zones",
|
||||
messageKey: "configMessages.onvif.autotrackingNoZones",
|
||||
severity: "error",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera") return false;
|
||||
const zones = ctx.fullCameraConfig?.zones;
|
||||
return (
|
||||
!zones ||
|
||||
typeof zones !== "object" ||
|
||||
Object.keys(zones).length === 0
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
host: {
|
||||
"ui:options": { size: "sm" },
|
||||
@ -57,16 +39,11 @@ const onvif: SectionConfigOverrides = {
|
||||
required_zones: {
|
||||
"ui:widget": "zoneNames",
|
||||
},
|
||||
return_preset: {
|
||||
"ui:options": { size: "sm" },
|
||||
"ui:widget": "ptzPresets",
|
||||
},
|
||||
track: {
|
||||
"ui:widget": "objectLabels",
|
||||
},
|
||||
zooming: {
|
||||
"ui:options": {
|
||||
size: "xs",
|
||||
enumI18nPrefix: "onvif.autotracking.zooming",
|
||||
},
|
||||
},
|
||||
|
||||
@ -21,10 +21,6 @@ const proxy: SectionConfigOverrides = {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
default_role: {
|
||||
"ui:widget": "defaultRole",
|
||||
"ui:options": { size: "sm" },
|
||||
},
|
||||
header_map: {
|
||||
"ui:after": { render: "ProxyRoleMap" },
|
||||
},
|
||||
|
||||
@ -10,7 +10,13 @@ const ui: SectionConfigOverrides = {
|
||||
overrideFields: [],
|
||||
},
|
||||
global: {
|
||||
fieldOrder: ["timezone", "time_format", "unit_system"],
|
||||
fieldOrder: [
|
||||
"timezone",
|
||||
"time_format",
|
||||
"date_style",
|
||||
"time_style",
|
||||
"unit_system",
|
||||
],
|
||||
advancedFields: [],
|
||||
restartRequired: ["unit_system"],
|
||||
uiSchema: {
|
||||
@ -20,6 +26,12 @@ const ui: SectionConfigOverrides = {
|
||||
time_format: {
|
||||
"ui:options": { enumI18nPrefix: "ui.timeFormat" },
|
||||
},
|
||||
date_style: {
|
||||
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
|
||||
},
|
||||
time_style: {
|
||||
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
|
||||
},
|
||||
unit_system: {
|
||||
"ui:options": { enumI18nPrefix: "ui.unitSystem" },
|
||||
},
|
||||
|
||||
@ -48,8 +48,6 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { isIOS } from "react-device-detect";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -439,12 +437,6 @@ export default function NotificationsSettingsExtras({
|
||||
}
|
||||
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
// iOS only exposes web push to apps installed to the Home Screen, so a
|
||||
// secure-context iOS browser tab that isn't an installed PWA has no
|
||||
// Notification API. Android supports web push in a normal tab, so it never
|
||||
// reaches this case and keeps the generic secure-context message.
|
||||
const requiresPwaInstall = isIOS && window.isSecureContext && !isPWA;
|
||||
|
||||
return (
|
||||
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
|
||||
<div className="w-full max-w-5xl">
|
||||
@ -473,21 +465,12 @@ export default function NotificationsSettingsExtras({
|
||||
{t("notification.notificationUnavailable.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
ns="views/settings"
|
||||
i18nKey={
|
||||
requiresPwaInstall
|
||||
? "notification.notificationUnavailable.descPwa"
|
||||
: "notification.notificationUnavailable.desc"
|
||||
}
|
||||
/>
|
||||
<Trans ns="views/settings">
|
||||
notification.notificationUnavailable.desc
|
||||
</Trans>
|
||||
<div className="mt-3 flex items-center">
|
||||
<Link
|
||||
to={getLocaleDocUrl(
|
||||
requiresPwaInstall
|
||||
? "configuration/notifications"
|
||||
: "configuration/authentication",
|
||||
)}
|
||||
to={getLocaleDocUrl("configuration/authentication")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
|
||||
@ -33,8 +33,6 @@ import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
|
||||
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
|
||||
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
|
||||
import { PTZPresetsWidget } from "./widgets/PTZPresetsWidget";
|
||||
import { DefaultRoleWidget } from "./widgets/DefaultRoleWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
@ -92,8 +90,6 @@ export const frigateTheme: FrigateTheme = {
|
||||
semanticSearchModel: SemanticSearchModelWidget,
|
||||
semanticSearchModelSize: SemanticSearchModelSizeWidget,
|
||||
onvifProfile: OnvifProfileWidget,
|
||||
ptzPresets: PTZPresetsWidget,
|
||||
defaultRole: DefaultRoleWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
const BUILT_IN_ROLES = ["admin", "viewer"];
|
||||
|
||||
export function DefaultRoleWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
||||
props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const roles = useMemo<string[]>(() => {
|
||||
const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {});
|
||||
// Keep admin/viewer first, then any custom roles in config order.
|
||||
const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r));
|
||||
return [...BUILT_IN_ROLES, ...custom];
|
||||
}, [formContext]);
|
||||
|
||||
const selectedValue = typeof value === "string" && value ? value : "viewer";
|
||||
|
||||
const getLabel = (role: string) =>
|
||||
BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger id={id} className={fieldClassName}>
|
||||
<SelectValue placeholder={schema.title} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{getLabel(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default DefaultRoleWidget;
|
||||
@ -1,151 +0,0 @@
|
||||
// Combobox widget for ONVIF PTZ preset fields (e.g. autotracking.return_preset).
|
||||
// Fetches the camera's PTZ presets and shows them in a dropdown, while still
|
||||
// allowing a typed custom value so existing presets that the camera does not
|
||||
// report (such as "home") are preserved.
|
||||
import { useState, useMemo } from "react";
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { CameraPtzInfo } from "@/types/ptz";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function PTZPresetsWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, options, registry } = props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const fieldClassName = getSizedFieldClassName(options, "md");
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const cameraName = formContext?.cameraName;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
|
||||
|
||||
const { data: ptzInfo } = useSWR<CameraPtzInfo>(
|
||||
isCameraLevel && cameraName && hasOnvifHost
|
||||
? `${cameraName}/ptz/info`
|
||||
: null,
|
||||
{
|
||||
// ONVIF may not be initialized yet when the settings page loads,
|
||||
// so retry until presets become available
|
||||
refreshInterval: (data) =>
|
||||
data?.presets && data.presets.length > 0 ? 0 : 5000,
|
||||
},
|
||||
);
|
||||
|
||||
const presets = useMemo<string[]>(() => ptzInfo?.presets ?? [], [ptzInfo]);
|
||||
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const matchesPreset = useMemo(
|
||||
() => presets.some((p) => p.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[presets, trimmedSearch],
|
||||
);
|
||||
const showCustomOption = trimmedSearch.length > 0 && !matchesPreset;
|
||||
|
||||
const commit = (next: string) => {
|
||||
onChange(next);
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const currentLabel = typeof value === "string" && value ? value : undefined;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchValue("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ?? t("configForm.ptzPresets.placeholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("configForm.ptzPresets.search")}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && showCustomOption) {
|
||||
e.preventDefault();
|
||||
commit(trimmedSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
{showCustomOption && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.ptzPresets.useCustom", {
|
||||
value: trimmedSearch,
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{presets.length > 0 ? (
|
||||
<CommandGroup heading={t("configForm.ptzPresets.available")}>
|
||||
{presets.map((preset) => (
|
||||
<CommandItem
|
||||
key={preset}
|
||||
value={preset}
|
||||
onSelect={() => commit(preset)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === preset ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{preset}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : !showCustomOption ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("configForm.ptzPresets.noPresets")}
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@ -13,13 +13,6 @@ import { useTimezone } from "@/hooks/use-date-utils";
|
||||
|
||||
type WeekStartsOnType = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
function formatCalendarDay(day: Date): string {
|
||||
const y = day.getFullYear();
|
||||
const m = String(day.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(day.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
type ReviewActivityCalendarProps = {
|
||||
reviewSummary?: ReviewSummary;
|
||||
recordingsSummary?: RecordingsSummary;
|
||||
@ -69,10 +62,17 @@ export default function ReviewActivityCalendar({
|
||||
}
|
||||
}
|
||||
|
||||
const formatDay = (day: Date) => {
|
||||
const y = day.getFullYear();
|
||||
const m = String(day.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(day.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
return {
|
||||
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
|
||||
alerts: (day: Date) => alertsSet.has(formatCalendarDay(day)),
|
||||
detections: (day: Date) => detectionsSet.has(formatCalendarDay(day)),
|
||||
recordings: (day: Date) => recordingsSet.has(formatDay(day)),
|
||||
alerts: (day: Date) => alertsSet.has(formatDay(day)),
|
||||
detections: (day: Date) => detectionsSet.has(formatDay(day)),
|
||||
};
|
||||
}, [reviewSummary, recordingsSummary]);
|
||||
|
||||
@ -156,32 +156,14 @@ type TimezoneAwareCalendarProps = {
|
||||
timezone?: string;
|
||||
selectedDay?: Date;
|
||||
onSelect: (day?: Date) => void;
|
||||
recordingsSummary?: RecordingsSummary;
|
||||
};
|
||||
export function TimezoneAwareCalendar({
|
||||
timezone,
|
||||
selectedDay,
|
||||
onSelect,
|
||||
recordingsSummary,
|
||||
}: TimezoneAwareCalendarProps) {
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
// When a recordings summary is supplied, underline days that have footage
|
||||
const recordingsModifier = useMemo(() => {
|
||||
if (!recordingsSummary) {
|
||||
return undefined;
|
||||
}
|
||||
const recordingsSet = new Set<string>();
|
||||
for (const date of Object.keys(recordingsSummary)) {
|
||||
if (date !== LAST_24_HOURS_KEY) {
|
||||
recordingsSet.add(date);
|
||||
}
|
||||
}
|
||||
return {
|
||||
recordings: (day: Date) => recordingsSet.has(formatCalendarDay(day)),
|
||||
};
|
||||
}, [recordingsSummary]);
|
||||
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
timezone ? Math.round(getUTCOffset(new Date(), timezone)) : undefined,
|
||||
@ -235,10 +217,6 @@ export function TimezoneAwareCalendar({
|
||||
onSelect={onSelect}
|
||||
defaultMonth={selectedDay ?? new Date()}
|
||||
weekStartsOn={(weekStartsOn ?? 0) as WeekStartsOnType}
|
||||
modifiers={recordingsModifier}
|
||||
components={
|
||||
recordingsModifier ? { DayButton: ReviewActivityDay } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
|
||||
|
||||
export const ANNOTATION_OFFSET_MIN = -10000;
|
||||
export const ANNOTATION_OFFSET_MAX = 10000;
|
||||
export const ANNOTATION_OFFSET_MAX = 5000;
|
||||
export const ANNOTATION_OFFSET_STEP = 50;
|
||||
|
||||
export const supportedLanguageKeys = [
|
||||
|
||||
@ -4,6 +4,8 @@ import { TriggerAction, TriggerType } from "./trigger";
|
||||
export interface UiConfig {
|
||||
timezone?: string;
|
||||
time_format?: "browser" | "12hour" | "24hour";
|
||||
date_style?: "full" | "long" | "medium" | "short";
|
||||
time_style?: "full" | "long" | "medium" | "short";
|
||||
dashboard: boolean;
|
||||
order: number;
|
||||
unit_system?: "metric" | "imperial";
|
||||
|
||||
@ -14,6 +14,7 @@ export interface MotionSearchRequest {
|
||||
parallel?: boolean;
|
||||
threshold?: number;
|
||||
min_area?: number;
|
||||
frame_skip?: number;
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
@ -42,21 +43,4 @@ export interface MotionSearchStatusResponse {
|
||||
total_frames_processed?: number;
|
||||
error_message?: string;
|
||||
metrics?: MotionSearchMetrics;
|
||||
scanning_timestamp?: number;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export interface MotionSearchJobResults {
|
||||
results: MotionSearchResult[];
|
||||
total_frames_processed: number;
|
||||
}
|
||||
|
||||
export interface MotionSearchJobPayload {
|
||||
id?: string;
|
||||
status: "queued" | "running" | "success" | "failed" | "cancelled";
|
||||
results?: MotionSearchJobResults;
|
||||
metrics?: MotionSearchMetrics;
|
||||
scanning_timestamp?: number;
|
||||
progress?: number;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import { LuHand, LuPencil } from "react-icons/lu";
|
||||
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { RecordingsSummary } from "@/types/review";
|
||||
import { ASPECT_PORTRAIT_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -45,11 +44,7 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { TimezoneAwareCalendar } from "@/components/overlay/ReviewActivityCalendar";
|
||||
|
||||
import { useApiHost } from "@/api";
|
||||
import {
|
||||
useFormattedTimestamp,
|
||||
use24HourTime,
|
||||
useTimezone,
|
||||
} from "@/hooks/use-date-utils";
|
||||
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import useSWR from "swr";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -74,6 +69,8 @@ type MotionSearchDialogProps = {
|
||||
setThreshold: React.Dispatch<React.SetStateAction<number>>;
|
||||
minArea: number;
|
||||
setMinArea: React.Dispatch<React.SetStateAction<number>>;
|
||||
frameSkip: number;
|
||||
setFrameSkip: React.Dispatch<React.SetStateAction<number>>;
|
||||
maxResults: number;
|
||||
setMaxResults: React.Dispatch<React.SetStateAction<number>>;
|
||||
searchRange?: TimeRange;
|
||||
@ -103,6 +100,8 @@ export default function MotionSearchDialog({
|
||||
setThreshold,
|
||||
minArea,
|
||||
setMinArea,
|
||||
frameSkip,
|
||||
setFrameSkip,
|
||||
maxResults,
|
||||
setMaxResults,
|
||||
searchRange,
|
||||
@ -118,16 +117,6 @@ export default function MotionSearchDialog({
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [panMode, setPanMode] = useState(false);
|
||||
|
||||
const recordingsTimezone = useTimezone(config);
|
||||
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
|
||||
selectedCamera
|
||||
? [
|
||||
"recordings/summary",
|
||||
{ timezone: recordingsTimezone, cameras: selectedCamera },
|
||||
]
|
||||
: null,
|
||||
);
|
||||
|
||||
const cameraConfig = useMemo(() => {
|
||||
if (!selectedCamera) return undefined;
|
||||
return config.cameras[selectedCamera];
|
||||
@ -448,6 +437,23 @@ export default function MotionSearchDialog({
|
||||
{t("settings.minAreaDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="frameSkip">{t("settings.frameSkip")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
id="frameSkip"
|
||||
min={1}
|
||||
max={120}
|
||||
step={1}
|
||||
value={[frameSkip]}
|
||||
onValueChange={([value]) => setFrameSkip(value)}
|
||||
/>
|
||||
<span className="w-12 text-sm">{frameSkip}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.frameSkipDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="parallelMode">
|
||||
@ -490,7 +496,6 @@ export default function MotionSearchDialog({
|
||||
setRange={setSearchRange}
|
||||
defaultRange={defaultRange}
|
||||
timezone={timezone}
|
||||
recordingsSummary={recordingsSummary}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@ -514,7 +519,6 @@ type SearchRangeSelectorProps = {
|
||||
setRange: React.Dispatch<React.SetStateAction<TimeRange | undefined>>;
|
||||
defaultRange: TimeRange;
|
||||
timezone?: string;
|
||||
recordingsSummary?: RecordingsSummary;
|
||||
};
|
||||
|
||||
function SearchRangeSelector({
|
||||
@ -522,7 +526,6 @@ function SearchRangeSelector({
|
||||
setRange,
|
||||
defaultRange,
|
||||
timezone,
|
||||
recordingsSummary,
|
||||
}: SearchRangeSelectorProps) {
|
||||
const { t } = useTranslation(["views/motionSearch", "common"]);
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
@ -627,7 +630,6 @@ function SearchRangeSelector({
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<TimezoneAwareCalendar
|
||||
timezone={timezone}
|
||||
recordingsSummary={recordingsSummary}
|
||||
selectedDay={new Date(startTime * 1000)}
|
||||
onSelect={(day) => {
|
||||
if (!day) {
|
||||
@ -694,7 +696,6 @@ function SearchRangeSelector({
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<TimezoneAwareCalendar
|
||||
timezone={timezone}
|
||||
recordingsSummary={recordingsSummary}
|
||||
selectedDay={new Date(endTime * 1000)}
|
||||
onSelect={(day) => {
|
||||
if (!day) {
|
||||
|
||||
@ -12,12 +12,10 @@ import { ExportMode } from "@/types/filter";
|
||||
import {
|
||||
MotionSearchRequest,
|
||||
MotionSearchStartResponse,
|
||||
MotionSearchStatusResponse,
|
||||
MotionSearchResult,
|
||||
MotionSearchMetrics,
|
||||
MotionSearchJobResults,
|
||||
MotionSearchJobPayload,
|
||||
} from "@/types/motionSearch";
|
||||
import { useJobStatus } from "@/api/ws";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@ -27,11 +25,6 @@ import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
import DynamicVideoPlayer from "@/components/player/dynamic/DynamicVideoPlayer";
|
||||
import { DynamicVideoController } from "@/components/player/dynamic/DynamicVideoController";
|
||||
@ -51,7 +44,7 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { getChunkedTimeDay } from "@/utils/timelineUtil";
|
||||
|
||||
import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review";
|
||||
import { MotionData, ZoomLevel } from "@/types/review";
|
||||
import {
|
||||
ASPECT_VERTICAL_LAYOUT,
|
||||
ASPECT_WIDE_LAYOUT,
|
||||
@ -59,17 +52,14 @@ import {
|
||||
RecordingSegment,
|
||||
} from "@/types/record";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
import {
|
||||
useFormattedTimestamp,
|
||||
useFormattedRange,
|
||||
use24HourTime,
|
||||
} from "@/hooks/use-date-utils";
|
||||
import { useFormattedTimestamp, use24HourTime } from "@/hooks/use-date-utils";
|
||||
import MotionSearchROICanvas from "./MotionSearchROICanvas";
|
||||
import MotionSearchDialog from "./MotionSearchDialog";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFire } from "react-icons/fa";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LuSearch, LuChevronRight, LuX } from "react-icons/lu";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
|
||||
type MotionSearchViewProps = {
|
||||
config: FrigateConfig;
|
||||
@ -128,11 +118,10 @@ export default function MotionSearchView({
|
||||
"actions" | "calendar"
|
||||
>("actions");
|
||||
|
||||
// Recordings summary for the calendars (main view + search dialog). Fetched
|
||||
// whenever a camera is selected so it is cached and ready by the time the
|
||||
// dialog's date pickers render, regardless of open/closed state.
|
||||
// Recordings summary for calendar – defer until dialog is closed
|
||||
// so the preview image in the dialog loads without competing requests
|
||||
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
|
||||
selectedCamera
|
||||
selectedCamera && !isSearchDialogOpen
|
||||
? [
|
||||
"recordings/summary",
|
||||
{
|
||||
@ -157,18 +146,17 @@ export default function MotionSearchView({
|
||||
const [parallelMode, setParallelMode] = useState(false);
|
||||
const [threshold, setThreshold] = useState(30);
|
||||
const [minArea, setMinArea] = useState(20);
|
||||
const [frameSkip, setFrameSkip] = useState(30);
|
||||
const [maxResults, setMaxResults] = useState(25);
|
||||
|
||||
// Job state
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [jobCamera, setJobCamera] = useState<string | null>(null);
|
||||
const { payload: jobPayload } =
|
||||
useJobStatus<MotionSearchJobResults>("motion_search");
|
||||
const jobStatus = jobPayload as MotionSearchJobPayload | null;
|
||||
const formattedScanningTimestamp = useFormattedTimestamp(
|
||||
jobStatus?.scanning_timestamp ?? 0,
|
||||
resultTimestampFormat,
|
||||
timezone,
|
||||
|
||||
// Job polling with SWR
|
||||
const { data: jobStatus } = useSWR<MotionSearchStatusResponse>(
|
||||
jobId && jobCamera ? [`${jobCamera}/search/motion/${jobId}`] : null,
|
||||
{ refreshInterval: 1000 },
|
||||
);
|
||||
|
||||
// Search state
|
||||
@ -182,15 +170,6 @@ export default function MotionSearchView({
|
||||
undefined,
|
||||
);
|
||||
const [pendingSeekTime, setPendingSeekTime] = useState<number | null>(null);
|
||||
const pendingSeekTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Formatted search window shown above the results (same date+time convention).
|
||||
const formattedSearchRange = useFormattedRange(
|
||||
searchRange?.after ?? 0,
|
||||
searchRange?.before ?? 0,
|
||||
resultTimestampFormat,
|
||||
timezone,
|
||||
);
|
||||
|
||||
// Export state
|
||||
const [exportMode, setExportMode] = useState<ExportMode>("none");
|
||||
@ -643,7 +622,7 @@ export default function MotionSearchView({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingSeekTimeRef.current != null) {
|
||||
if (pendingSeekTime != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -657,7 +636,7 @@ export default function MotionSearchView({
|
||||
setPlaybackStart(nextTime);
|
||||
setSelectedRangeIdx(index === -1 ? chunkedTimeRange.length - 1 : index);
|
||||
mainControllerRef.current?.seekToTimestamp(nextTime, true);
|
||||
}, [timeRange, chunkedTimeRange]);
|
||||
}, [pendingSeekTime, timeRange, chunkedTimeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrubbing) {
|
||||
@ -754,9 +733,10 @@ export default function MotionSearchView({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelMotionSearchJobViaBeacon(jobIdRef.current, jobCameraRef.current);
|
||||
void cancelMotionSearchJob(jobIdRef.current, jobCameraRef.current);
|
||||
};
|
||||
}, [cancelMotionSearchJob]);
|
||||
}, [cancelMotionSearchJob, cancelMotionSearchJobViaBeacon]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
@ -822,6 +802,7 @@ export default function MotionSearchView({
|
||||
parallel: parallelMode,
|
||||
threshold,
|
||||
min_area: minArea,
|
||||
frame_skip: frameSkip,
|
||||
max_results: maxResults,
|
||||
};
|
||||
|
||||
@ -896,6 +877,7 @@ export default function MotionSearchView({
|
||||
parallelMode,
|
||||
threshold,
|
||||
minArea,
|
||||
frameSkip,
|
||||
maxResults,
|
||||
t,
|
||||
]);
|
||||
@ -906,27 +888,23 @@ export default function MotionSearchView({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jobId || jobStatus.id !== jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultList = jobStatus.results?.results;
|
||||
|
||||
if (jobStatus.status === "success") {
|
||||
setSearchResults(resultList ?? []);
|
||||
setSearchResults(jobStatus.results ?? []);
|
||||
setSearchMetrics(jobStatus.metrics ?? null);
|
||||
setIsSearching(false);
|
||||
setJobId(null);
|
||||
setJobCamera(null);
|
||||
toast.success(t("changesFound", { count: resultList?.length ?? 0 }));
|
||||
toast.success(
|
||||
t("changesFound", { count: jobStatus.results?.length ?? 0 }),
|
||||
);
|
||||
} else if (
|
||||
jobStatus.status === "queued" ||
|
||||
jobStatus.status === "running"
|
||||
) {
|
||||
setSearchMetrics(jobStatus.metrics ?? null);
|
||||
// Stream partial results as they arrive
|
||||
if (resultList && resultList.length > 0) {
|
||||
setSearchResults(resultList);
|
||||
if (jobStatus.results && jobStatus.results.length > 0) {
|
||||
setSearchResults(jobStatus.results);
|
||||
}
|
||||
} else if (jobStatus.status === "failed") {
|
||||
setIsSearching(false);
|
||||
@ -934,7 +912,7 @@ export default function MotionSearchView({
|
||||
setJobCamera(null);
|
||||
toast.error(
|
||||
t("errors.searchFailed", {
|
||||
message: jobStatus.error_message || t("errors.unknown"),
|
||||
message: jobStatus.error_message || jobStatus.message,
|
||||
}),
|
||||
);
|
||||
} else if (jobStatus.status === "cancelled") {
|
||||
@ -943,7 +921,7 @@ export default function MotionSearchView({
|
||||
setJobCamera(null);
|
||||
toast.message(t("searchCancelled"));
|
||||
}
|
||||
}, [jobStatus, jobId, t]);
|
||||
}, [jobStatus, t]);
|
||||
|
||||
// Handle result click
|
||||
const handleResultClick = useCallback(
|
||||
@ -952,14 +930,12 @@ export default function MotionSearchView({
|
||||
result.timestamp < timeRange.after ||
|
||||
result.timestamp > timeRange.before
|
||||
) {
|
||||
pendingSeekTimeRef.current = result.timestamp;
|
||||
setPendingSeekTime(result.timestamp);
|
||||
onDaySelect(new Date(result.timestamp * 1000));
|
||||
return;
|
||||
}
|
||||
|
||||
// start playback a few seconds before the change so the motion is in view
|
||||
manuallySetCurrentTime(result.timestamp - REVIEW_PADDING, true);
|
||||
manuallySetCurrentTime(result.timestamp, true);
|
||||
},
|
||||
[manuallySetCurrentTime, onDaySelect, timeRange],
|
||||
);
|
||||
@ -973,9 +949,8 @@ export default function MotionSearchView({
|
||||
pendingSeekTime >= timeRange.after &&
|
||||
pendingSeekTime <= timeRange.before
|
||||
) {
|
||||
manuallySetCurrentTime(pendingSeekTime - REVIEW_PADDING, true);
|
||||
manuallySetCurrentTime(pendingSeekTime, true);
|
||||
setPendingSeekTime(null);
|
||||
pendingSeekTimeRef.current = null;
|
||||
}
|
||||
}, [pendingSeekTime, timeRange, manuallySetCurrentTime]);
|
||||
|
||||
@ -1053,9 +1028,6 @@ export default function MotionSearchView({
|
||||
|
||||
const progressMetrics = jobStatus?.metrics ?? searchMetrics;
|
||||
const progressValue = (() => {
|
||||
if (jobStatus?.progress != null) {
|
||||
return Math.min(100, Math.max(0, jobStatus.progress * 100));
|
||||
}
|
||||
if (!progressMetrics || progressMetrics.segments_scanned <= 0) {
|
||||
return 0;
|
||||
}
|
||||
@ -1070,48 +1042,23 @@ export default function MotionSearchView({
|
||||
return Math.min(100, Math.max(0, (doneWork / totalWork) * 100));
|
||||
})();
|
||||
|
||||
const wallTimeLabel = searchMetrics
|
||||
? searchMetrics.wall_time_seconds >= 60
|
||||
? t("metrics.minutesSeconds", {
|
||||
minutes: Math.floor(searchMetrics.wall_time_seconds / 60),
|
||||
seconds: Math.round(searchMetrics.wall_time_seconds % 60),
|
||||
})
|
||||
: t("metrics.seconds", {
|
||||
seconds: searchMetrics.wall_time_seconds.toFixed(1),
|
||||
})
|
||||
: "";
|
||||
|
||||
const resultsPanel = (
|
||||
<>
|
||||
{(hasSearched || isSearching) && (
|
||||
<div className="flex flex-col gap-1 px-3 py-2.5">
|
||||
{searchRange && (
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{formattedSearchRange}
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("metrics.scanSummary", {
|
||||
segments: searchMetrics.segments_scanned,
|
||||
time: wallTimeLabel,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2">
|
||||
<h3 className="font-medium">{t("results")}</h3>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block">
|
||||
<ScrollArea className="flex-1">
|
||||
{isSearching && (
|
||||
<div className="flex flex-col gap-1.5 border-b p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress className="h-1.5 flex-1" value={progressValue} />
|
||||
<div className="flex flex-col gap-2 border-b p-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-1 text-wrap">
|
||||
<ActivityIndicator className="mr-2 size-4" />
|
||||
<div>{t("searching")}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0 text-muted-foreground"
|
||||
aria-label={t("cancelSearch")}
|
||||
title={t("cancelSearch")}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void cancelMotionSearchJob(jobId, jobCamera);
|
||||
setIsSearching(false);
|
||||
@ -1120,90 +1067,76 @@ export default function MotionSearchView({
|
||||
toast.success(t("searchCancelled"));
|
||||
}}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
{t("cancelSearch")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{jobStatus?.scanning_timestamp != null
|
||||
? t("scanning", { time: formattedScanningTimestamp })
|
||||
: t("searching")}
|
||||
</div>
|
||||
<Progress className="h-1" value={progressValue} />
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics &&
|
||||
(isSearching || searchResults.length > 0 || hasSearched) && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-1 px-3 py-2.5 text-left text-xs text-muted-foreground hover:bg-accent">
|
||||
<LuChevronRight className="size-3 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
|
||||
{t("metrics.title")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-0.5 px-3 pb-3 text-xs text-muted-foreground">
|
||||
{searchMetrics.segments_processed > 0 && (
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>{t("metrics.segmentsProcessed")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.segments_processed}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics.metadata_inactive_segments > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.segmentsSkippedInactive")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.metadata_inactive_segments}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics.heatmap_roi_skip_segments > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.heatmap_roi_skip_segments}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics.fallback_full_range_segments > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.fallbackFullRange")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.fallback_full_range_segments}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.framesDecoded")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.frames_decoded}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.wallTime")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{wallTimeLabel}
|
||||
</span>
|
||||
</div>
|
||||
{searchMetrics.segments_with_errors > 0 && (
|
||||
<div className="flex justify-between text-destructive">
|
||||
<span>{t("metrics.segmentErrors")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.segments_with_errors}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics && (isSearching || searchResults.length > 0) && (
|
||||
<div className="mx-2 my-3 rounded-lg border bg-secondary p-2">
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.segmentsScanned")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.segments_scanned}
|
||||
</span>
|
||||
</div>
|
||||
{searchMetrics.segments_processed > 0 && (
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>{t("metrics.segmentsProcessed")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.segments_processed}
|
||||
</span>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{(searchResults.length > 0 || (hasSearched && !isSearching)) && (
|
||||
<div className="border-t px-1.5 pb-1.5 pt-3">
|
||||
<h3 className="text-sm font-medium tracking-wide text-muted-foreground">
|
||||
{searchResults.length > 0 && (
|
||||
<span className="ml-1.5">{searchResults.length}</span>
|
||||
)}{" "}
|
||||
{t("results")}
|
||||
</h3>
|
||||
)}
|
||||
{searchMetrics.metadata_inactive_segments > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.segmentsSkippedInactive")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.metadata_inactive_segments}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics.heatmap_roi_skip_segments > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.segmentsSkippedHeatmap")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.heatmap_roi_skip_segments}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{searchMetrics.fallback_full_range_segments > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.fallbackFullRange")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.fallback_full_range_segments}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.framesDecoded")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.frames_decoded}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("metrics.wallTime")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{t("metrics.seconds", {
|
||||
seconds: searchMetrics.wall_time_seconds.toFixed(1),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{searchMetrics.segments_with_errors > 0 && (
|
||||
<div className="flex justify-between text-destructive">
|
||||
<span>{t("metrics.segmentErrors")}</span>
|
||||
<span className="text-primary-variant">
|
||||
{searchMetrics.segments_with_errors}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1212,7 +1145,7 @@ export default function MotionSearchView({
|
||||
{hasSearched ? t("noChangesFound") : t("noResultsYet")}
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="flex flex-col gap-1 px-1 pb-2">
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
{searchResults.map((result, index) => (
|
||||
<SearchResultItem
|
||||
key={index}
|
||||
@ -1254,6 +1187,8 @@ export default function MotionSearchView({
|
||||
setThreshold={setThreshold}
|
||||
minArea={minArea}
|
||||
setMinArea={setMinArea}
|
||||
frameSkip={frameSkip}
|
||||
setFrameSkip={setFrameSkip}
|
||||
maxResults={maxResults}
|
||||
setMaxResults={setMaxResults}
|
||||
searchRange={searchRange}
|
||||
@ -1581,20 +1516,15 @@ function SearchResultItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md p-2 text-left hover:bg-accent"
|
||||
className="flex w-full flex-col rounded-md p-2 text-left hover:bg-accent"
|
||||
onClick={onClick}
|
||||
title={t("jumpToTime")}
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{formattedTime}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-xs tabular-nums text-muted-foreground"
|
||||
title={t("changePercentage", {
|
||||
<span className="text-sm font-medium">{formattedTime}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("changePercentage", {
|
||||
percentage: result.change_percentage.toFixed(1),
|
||||
})}
|
||||
>
|
||||
{result.change_percentage.toFixed(1)}%
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -902,7 +902,7 @@ function StreamUrlEntry({
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<div className="flex h-7 flex-row items-center justify-start gap-2 text-sm text-primary-variant">
|
||||
{t("go2rtcStreams.sourceNumber", { index: urlIndex + 1 })}
|
||||
{t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })}
|
||||
{canRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -53,7 +53,6 @@ export default function MasksAndZonesView({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
|
||||
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
|
||||
const [polygonsInitialized, setPolygonsInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
|
||||
number | undefined
|
||||
@ -610,7 +609,6 @@ export default function MasksAndZonesView({
|
||||
...globalObjectMasks,
|
||||
...objectMasks,
|
||||
]);
|
||||
setPolygonsInitialized(true);
|
||||
// Don't overwrite editingPolygons during editing – layout shifts
|
||||
// from switching to the edit pane can trigger a resize which
|
||||
// recalculates scaledWidth/scaledHeight and would discard the
|
||||
@ -678,7 +676,7 @@ export default function MasksAndZonesView({
|
||||
}, [currentEditingProfile]);
|
||||
|
||||
useSearchEffect("object_mask", (coordinates: string) => {
|
||||
if (!scaledWidth || !scaledHeight || isLoading || !polygonsInitialized) {
|
||||
if (!scaledWidth || !scaledHeight || isLoading) {
|
||||
return false;
|
||||
}
|
||||
// convert box points string to points array
|
||||
|
||||
@ -484,7 +484,7 @@ export default function TriggerView({
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-5 flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex max-w-5xl flex-col items-start">
|
||||
<div className="flex flex-col items-start">
|
||||
<Heading as="h4" className="mb-1">
|
||||
{t("triggers.management.title")}
|
||||
</Heading>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user