mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 07:18:57 +03:00
* fix recordings unavailable endpoint when no params are provided
we already import the datetime class directly, so those attribute lookups raised AttributeError and the request returned 500
* reject JWTs whose role is no longer in the config
`/auth` trusted the role inside the JWT and re-signed it on refresh without checking the config, so a user whose restricted role was deleted kept a session carrying a role that isn't in `auth.roles`. The media, clip, recording, export and go2rtc checks treat a missing role the same as a role with no camera list, so that session could open every camera. `/auth` now returns 401 for a token whose role isn't configured, which sends the user back through login, and login already falls back to `viewer` for a role that's gone.
* delete the deleted camera group's layout, not the open one's
Deleting a camera group removed the layout of the group being viewed, because the dialog's delete was bound to `${activeGroup}-draggable-layout`. It also cleared the saved group, and both ran before the `config/set` request whether or not it succeeded, so deleting one group while viewing another lost the open group's layout and left the deleted group's layout behind. The deleted group's own layout is now removed after a successful save, and the saved group is only cleared when it was the open group.
* don't block API when querying PTZ info
camera_ptz_info is async but waited on the ONVIF controller's future with future.result(), blocking the API event loop for as long as a slow or unreachable ONVIF camera took to answer (including reconnect attempts), so every other async request stalled with it. Await the future with asyncio.wrap_future instead. The coroutine runs on the controller's own loop and thread, so this cannot deadlock.
* for custom exports, only allow admin users to add to existing cases
follows the existing convention where attaching an export to an existing case is admin-only on `POST /export/{camera}/...` and `POST /exports/batch`
* drop pending edits for a camera or profile that no longer exists
* match cached preview frames to their camera exactly
https://github.com/blakeblackshear/frigate/pull/22594 added a trailing `-` to the `preview_{camera}` prefix so `camera` stopped matching `camera2`'s frames, but camera names can contain `-`, so `front` still matched `front-door`'s. After a restart, `front`'s preview recorder deleted this hour's frames of a matching camera that sorted before it and added the timestamps of one that sorted after it, so ffmpeg was asked for files that don't exist and that hour's preview was lost. The offline fallback for `latest.jpg` could also return `front-door`'s frame for `front`, even to a user without access to `front-door`, and a short export could take its fallback thumbnail from the other camera. These now compare the full camera name taken from the file name.
482 lines
16 KiB
Python
482 lines
16 KiB
Python
"""Handle outputting low res / fps preview segments from decoded frames."""
|
|
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess as sp
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from frigate.comms.inter_process import InterProcessRequestor
|
|
from frigate.config import CameraConfig, RecordQualityEnum
|
|
from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE
|
|
from frigate.ffmpeg_presets import (
|
|
FPS_VFR_PARAM,
|
|
EncodeTypeEnum,
|
|
parse_preset_hardware_acceleration_encode,
|
|
)
|
|
from frigate.models import Previews
|
|
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
|
|
from frigate.util.ownership import chown_to_runtime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FOLDER_PREVIEW_FRAMES = "preview_frames"
|
|
PREVIEW_CACHE_DIR = os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES)
|
|
PREVIEW_SEGMENT_DURATION = 3600 # one hour
|
|
# important to have lower keyframe to maintain scrubbing performance
|
|
PREVIEW_KEYFRAME_INTERVAL = 40
|
|
PREVIEW_HEIGHT = 180
|
|
PREVIEW_QUALITY_WEBP = {
|
|
RecordQualityEnum.very_low: 70,
|
|
RecordQualityEnum.low: 80,
|
|
RecordQualityEnum.medium: 80,
|
|
RecordQualityEnum.high: 80,
|
|
RecordQualityEnum.very_high: 86,
|
|
}
|
|
PREVIEW_QUALITY_BIT_RATES = {
|
|
RecordQualityEnum.very_low: 7168,
|
|
RecordQualityEnum.low: 8196,
|
|
RecordQualityEnum.medium: 9216,
|
|
RecordQualityEnum.high: 9864,
|
|
RecordQualityEnum.very_high: 10096,
|
|
}
|
|
# the -qmax param for ffmpeg prevents the encoder from overly compressing frames while still trying to hit the bitrate target
|
|
# lower values are higher quality. This is especially important for iniitial frames in the segment
|
|
PREVIEW_QMAX_PARAM = {
|
|
RecordQualityEnum.very_low: "",
|
|
RecordQualityEnum.low: "",
|
|
RecordQualityEnum.medium: "",
|
|
RecordQualityEnum.high: " -qmax 25",
|
|
RecordQualityEnum.very_high: " -qmax 25",
|
|
}
|
|
|
|
|
|
def get_cache_image_name(camera: str, frame_time: float) -> str:
|
|
"""Get the image name in cache."""
|
|
return os.path.join(
|
|
CACHE_DIR,
|
|
f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.{PREVIEW_FRAME_TYPE}",
|
|
)
|
|
|
|
|
|
def is_camera_preview_frame(file_name: str, camera: str) -> bool:
|
|
"""Check whether a cached preview frame file belongs to the camera."""
|
|
# camera names may contain "-", so a prefix match would include "front-door"
|
|
return file_name.rsplit("-", 1)[0] == f"preview_{camera}"
|
|
|
|
|
|
def get_most_recent_preview_frame(
|
|
camera: str, before: float | None = None
|
|
) -> str | None:
|
|
"""Get the most recent preview frame for a camera."""
|
|
if not os.path.exists(PREVIEW_CACHE_DIR):
|
|
return None
|
|
|
|
try:
|
|
# files are named preview_{camera}-{timestamp}.webp
|
|
# we want the largest timestamp that is less than or equal to before
|
|
preview_files = [
|
|
f
|
|
for f in os.listdir(PREVIEW_CACHE_DIR)
|
|
if is_camera_preview_frame(f, camera)
|
|
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
|
|
]
|
|
|
|
if not preview_files:
|
|
return None
|
|
|
|
# sort by timestamp in descending order
|
|
# filenames are like preview_front-1712345678.901234.webp
|
|
preview_files.sort(reverse=True)
|
|
|
|
if before is None:
|
|
return os.path.join(PREVIEW_CACHE_DIR, preview_files[0])
|
|
|
|
for file_name in preview_files:
|
|
try:
|
|
# Extract timestamp: preview_front-1712345678.901234.webp
|
|
# Split by dash and extension
|
|
timestamp_part = file_name.split("-")[-1].split(
|
|
f".{PREVIEW_FRAME_TYPE}"
|
|
)[0]
|
|
timestamp = float(timestamp_part)
|
|
|
|
if timestamp <= before:
|
|
return os.path.join(PREVIEW_CACHE_DIR, file_name)
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error searching for most recent preview frame: {e}")
|
|
return None
|
|
|
|
|
|
class FFMpegConverter(threading.Thread):
|
|
"""Convert a list of still frames into a vfr mp4."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: CameraConfig,
|
|
frame_times: list[float],
|
|
requestor: InterProcessRequestor,
|
|
):
|
|
super().__init__(name=f"{config.name}_preview_converter")
|
|
self.config = config
|
|
self.frame_times = frame_times
|
|
self.requestor = requestor
|
|
self.path = os.path.join(
|
|
CLIPS_DIR,
|
|
f"previews/{self.config.name}/{self.frame_times[0]}-{self.frame_times[-1]}.mp4",
|
|
)
|
|
|
|
# write a PREVIEW at fps and 1 key frame per clip
|
|
self.ffmpeg_cmd = parse_preset_hardware_acceleration_encode(
|
|
config.ffmpeg.ffmpeg_path,
|
|
"default",
|
|
input="-f concat -y -protocol_whitelist pipe,file -safe 0 -threads 1 -i /dev/stdin",
|
|
output=f"-threads 1 -g {PREVIEW_KEYFRAME_INTERVAL} -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]}{PREVIEW_QMAX_PARAM[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}",
|
|
type=EncodeTypeEnum.preview,
|
|
)
|
|
|
|
def run(self) -> None:
|
|
# generate input list
|
|
item_count = len(self.frame_times)
|
|
playlist = []
|
|
|
|
for t_idx in range(0, item_count):
|
|
if t_idx == item_count - 1:
|
|
# last frame does not get a duration
|
|
playlist.append(
|
|
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
|
|
)
|
|
continue
|
|
|
|
playlist.append(
|
|
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
|
|
)
|
|
playlist.append(
|
|
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
|
|
)
|
|
|
|
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
p = sp.run(
|
|
self.ffmpeg_cmd.split(" "),
|
|
input="\n".join(playlist),
|
|
encoding="ascii",
|
|
capture_output=True,
|
|
)
|
|
except BlockingIOError:
|
|
logger.warning(
|
|
f"Failed to create preview for {self.config.name}, retrying..."
|
|
)
|
|
time.sleep(2)
|
|
p = sp.run(
|
|
self.ffmpeg_cmd.split(" "),
|
|
input="\n".join(playlist),
|
|
encoding="ascii",
|
|
capture_output=True,
|
|
)
|
|
|
|
start = self.frame_times[0]
|
|
end = self.frame_times[-1]
|
|
|
|
if p.returncode == 0:
|
|
logger.debug("successfully saved preview")
|
|
chown_to_runtime(self.path)
|
|
self.requestor.send_data(
|
|
INSERT_PREVIEW,
|
|
{
|
|
Previews.id.name: f"{self.config.name}_{end}",
|
|
Previews.camera.name: self.config.name,
|
|
Previews.path.name: self.path,
|
|
Previews.start_time.name: start,
|
|
Previews.end_time.name: end,
|
|
Previews.duration.name: end - start,
|
|
},
|
|
)
|
|
else:
|
|
logger.error(f"Error saving preview for {self.config.name} :: {p.stderr}")
|
|
|
|
# unlink files from cache
|
|
# don't delete last frame as it will be used as first frame in next segment
|
|
for t in self.frame_times[0:-1]:
|
|
Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) # type: ignore[arg-type]
|
|
|
|
|
|
class PreviewRecorder:
|
|
def __init__(self, config: CameraConfig) -> None:
|
|
self.config = config
|
|
self.camera_name: str = config.name or ""
|
|
self.start_time: float = 0
|
|
self.last_output_time: float = 0
|
|
self.offline = False
|
|
self.output_frames: list[float] = []
|
|
|
|
if config.detect.width is None or config.detect.height is None:
|
|
raise ValueError("Detect width and height must be set for previews.")
|
|
|
|
self.detect_width: int = config.detect.width
|
|
self.detect_height: int = config.detect.height
|
|
|
|
if self.detect_width > self.detect_height:
|
|
self.out_height = PREVIEW_HEIGHT
|
|
self.out_width = (
|
|
int((self.detect_width / self.detect_height) * self.out_height) // 4 * 4
|
|
)
|
|
else:
|
|
self.out_width = PREVIEW_HEIGHT
|
|
self.out_height = (
|
|
int((self.detect_height / self.detect_width) * self.out_width) // 4 * 4
|
|
)
|
|
|
|
# create communication for finished previews
|
|
self.requestor = InterProcessRequestor()
|
|
|
|
y, u1, u2, v1, v2 = get_yuv_crop(
|
|
self.config.frame_shape_yuv,
|
|
(
|
|
0,
|
|
0,
|
|
self.config.frame_shape[1],
|
|
self.config.frame_shape[0],
|
|
),
|
|
)
|
|
self.channel_dims = {
|
|
"y": y,
|
|
"u1": u1,
|
|
"u2": u2,
|
|
"v1": v1,
|
|
"v2": v2,
|
|
}
|
|
|
|
# end segment at end of hour (use UTC to avoid DST issues)
|
|
self.segment_end = (
|
|
(datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1))
|
|
.replace(minute=0, second=0, microsecond=0)
|
|
.timestamp()
|
|
)
|
|
|
|
Path(PREVIEW_CACHE_DIR).mkdir(exist_ok=True)
|
|
Path(os.path.join(CLIPS_DIR, f"previews/{config.name}")).mkdir(
|
|
parents=True, exist_ok=True
|
|
)
|
|
|
|
# check for existing items in cache
|
|
start_ts = (
|
|
datetime.datetime.now(datetime.UTC)
|
|
.replace(minute=0, second=0, microsecond=0)
|
|
.timestamp()
|
|
)
|
|
|
|
file_start = f"preview_{config.name}-"
|
|
start_file = f"{file_start}{start_ts}.webp"
|
|
|
|
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
|
|
if not is_camera_preview_frame(file, self.camera_name):
|
|
continue
|
|
|
|
if file < start_file:
|
|
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
|
|
continue
|
|
|
|
try:
|
|
file_time = file.split("-")[-1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
|
|
|
|
if not file_time:
|
|
continue
|
|
|
|
ts = float(file_time)
|
|
except ValueError:
|
|
continue
|
|
|
|
if self.start_time == 0:
|
|
self.start_time = ts
|
|
|
|
self.last_output_time = ts
|
|
self.output_frames.append(ts)
|
|
|
|
def reset_frame_cache(self, frame_time: float) -> None:
|
|
self.segment_end = (
|
|
(
|
|
datetime.datetime.fromtimestamp(frame_time, tz=datetime.UTC)
|
|
+ datetime.timedelta(hours=1)
|
|
)
|
|
.replace(minute=0, second=0, microsecond=0)
|
|
.timestamp()
|
|
)
|
|
self.start_time = frame_time
|
|
self.last_output_time = frame_time
|
|
self.output_frames = []
|
|
|
|
def should_write_frame(
|
|
self,
|
|
current_tracked_objects: list[dict[str, Any]],
|
|
motion_boxes: list[list[int]],
|
|
frame_time: float,
|
|
) -> bool:
|
|
"""Decide if this frame should be added to PREVIEW."""
|
|
if not self.config.record.enabled:
|
|
return False
|
|
|
|
active_objs = get_active_objects(
|
|
frame_time, self.config, current_tracked_objects
|
|
)
|
|
|
|
preview_output_fps = 2 if any(o["label"] == "car" for o in active_objs) else 1
|
|
|
|
# limit output to 1 fps
|
|
if (frame_time - self.last_output_time) < 1 / preview_output_fps:
|
|
return False
|
|
|
|
# send frame if a non-stationary object is in a zone
|
|
if len(active_objs) > 0:
|
|
self.last_output_time = frame_time
|
|
return True
|
|
|
|
if len(motion_boxes) > 0:
|
|
self.last_output_time = frame_time
|
|
return True
|
|
|
|
# ensure that at least 2 frames are written every minute
|
|
if frame_time - self.last_output_time > 30:
|
|
self.last_output_time = frame_time
|
|
return True
|
|
|
|
return False
|
|
|
|
def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> None:
|
|
# resize yuv frame
|
|
small_frame: np.ndarray = np.zeros(
|
|
(self.out_height * 3 // 2, self.out_width), np.uint8
|
|
)
|
|
copy_yuv_to_position(
|
|
small_frame,
|
|
(0, 0),
|
|
(self.out_height, self.out_width),
|
|
frame,
|
|
self.channel_dims,
|
|
cv2.INTER_AREA,
|
|
)
|
|
small_frame = cv2.cvtColor(
|
|
small_frame,
|
|
cv2.COLOR_YUV2BGR_I420,
|
|
)
|
|
cache_path = get_cache_image_name(self.camera_name, frame_time)
|
|
|
|
if not cv2.imwrite(
|
|
cache_path,
|
|
small_frame,
|
|
[
|
|
int(cv2.IMWRITE_WEBP_QUALITY),
|
|
PREVIEW_QUALITY_WEBP[self.config.record.preview.quality],
|
|
],
|
|
):
|
|
logger.error("Failed to write preview frame to %s", cache_path)
|
|
|
|
def write_data(
|
|
self,
|
|
current_tracked_objects: list[dict[str, Any]],
|
|
motion_boxes: list[list[int]],
|
|
frame_time: float,
|
|
frame: np.ndarray,
|
|
) -> None:
|
|
self.offline = False
|
|
|
|
# always write the first frame
|
|
if self.start_time == 0:
|
|
self.start_time = frame_time
|
|
self.output_frames.append(frame_time)
|
|
self.write_frame_to_cache(frame_time, frame)
|
|
return
|
|
|
|
# check if PREVIEW clip should be generated and cached frames reset
|
|
if frame_time >= self.segment_end:
|
|
if len(self.output_frames) > 0:
|
|
# save last frame to ensure consistent duration
|
|
if self.config.record:
|
|
self.output_frames.append(frame_time)
|
|
self.write_frame_to_cache(frame_time, frame)
|
|
|
|
# write the preview if any frames exist for this hour
|
|
FFMpegConverter(
|
|
self.config,
|
|
self.output_frames,
|
|
self.requestor,
|
|
).start()
|
|
else:
|
|
logger.debug(
|
|
f"Not saving preview for {self.camera_name} because there are no saved frames."
|
|
)
|
|
|
|
self.reset_frame_cache(frame_time)
|
|
|
|
# include first frame to ensure consistent duration
|
|
if self.config.record.enabled:
|
|
self.output_frames.append(frame_time)
|
|
self.write_frame_to_cache(frame_time, frame)
|
|
|
|
return
|
|
elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time):
|
|
self.output_frames.append(frame_time)
|
|
self.write_frame_to_cache(frame_time, frame)
|
|
return
|
|
|
|
def flag_offline(self, frame_time: float) -> None:
|
|
if not self.offline:
|
|
self.write_frame_to_cache(
|
|
frame_time,
|
|
get_blank_yuv_frame(self.detect_width, self.detect_height),
|
|
)
|
|
self.offline = True
|
|
|
|
# check if PREVIEW clip should be generated and cached frames reset
|
|
if frame_time >= self.segment_end:
|
|
if len(self.output_frames) == 0:
|
|
# camera has been offline for entire hour
|
|
# we have no preview to create
|
|
self.reset_frame_cache(frame_time)
|
|
return
|
|
|
|
old_frame_path = get_cache_image_name(
|
|
self.camera_name, self.output_frames[-1]
|
|
)
|
|
new_frame_path = get_cache_image_name(self.camera_name, frame_time)
|
|
shutil.copy(old_frame_path, new_frame_path)
|
|
|
|
# save last frame to ensure consistent duration
|
|
self.output_frames.append(frame_time)
|
|
FFMpegConverter(
|
|
self.config,
|
|
self.output_frames,
|
|
self.requestor,
|
|
).start()
|
|
|
|
self.reset_frame_cache(frame_time)
|
|
|
|
def stop(self) -> None:
|
|
self.requestor.stop()
|
|
|
|
|
|
def get_active_objects(
|
|
frame_time: float, camera_config: CameraConfig, all_objects: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
"""get active objects for detection."""
|
|
return [
|
|
o
|
|
for o in all_objects
|
|
if o["motionless_count"] < camera_config.detect.stationary.threshold
|
|
and o["position_changes"] > 0
|
|
and o["frame_time"] == frame_time
|
|
and not o["false_positive"]
|
|
]
|