mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-26 00:14:41 +03:00
* materialize event counts in metrics endpoint The metrics endpoint stored a lazy peewee query on the shared Prometheus collector, and collect() ran it later. Overlapping scrapes run in separate threadpool threads, so two requests could execute and step the same query's sqlite cursor at once. The rows are now read with list() before they reach the collector. * fix fps overflow restarting ffmpeg after 3 seconds instead of 3 retry intervals This is a regression from 0.18. The watchdog has restarted ffmpeg after 3 consecutive fps overflow checks since #13651, which was 3 retry intervals (30s by default) when the loop ran every retry_interval. #21297 moved the loop to 1s ticks without converting the count, so the window shrank to 3s. camera_fps is a 10s average, so one burst of buffered frames after a stream hiccup could keep it over the limit long enough to restart a healthy camera, and the next reconnect burst could repeat it. The threshold is now 3 * retry_interval ticks. When the count filled while restarts were still rate limited, the watchdog logged "Exiting ffmpeg...", marked the camera offline and reset the count without restarting. The check now waits for can_restart, so the log, status and restart always happen together. * keep recording cleanup running after a failed pass An exception anywhere in a cleanup pass, such as an OSError from a read-only or unmounted storage path, ended the recording cleanup thread with nothing logged. Retention stopped until restart, and recordings, previews and database rows kept growing. Each pass now logs the exception and the loop continues. * expose frigate_camera_events as a gauge The metric reports the current count of events in the database, which drops when retention prunes old events. As a counter, each prune read as a counter reset, so increase() and rate() inflated event counts. The series name changes from frigate_camera_events_total to frigate_camera_events. The test's porch/inside assertion was missing its `in event.text` and always passed. * keep uvicorn idle connections open longer than nginx's upstream keepalive uvicorn closed idle connections after its 5s default while nginx kept them pooled for 60s, so a request nginx sent on a connection uvicorn was closing got a 502. nginx retries that on a fresh connection for most methods, but not for POST or PATCH. * snapshot websocket clients under the manager lock in the output loop The output loop iterated ws4py's live websocket dict without its lock, so a client connecting or disconnecting mid-iteration raised "dictionary changed size during iteration" and killed the output loop. It now copies the clients under manager.lock once per frame and skips terminated ones, matching the jsmpeg and birdseye broadcasters. * only cache preview frames when recording is enabled The segment-end check tested the record config object instead of record.enabled, and the first frame after startup was written unconditionally. With recording disabled, the first segment still cached frames and encoded a preview. * fix untranslated unknown resolution text in camera info dialog * detect all AMD GPUs from vainfo The vainfo check only matched "AMD Radeon Graphics", so drivers reporting names like "AMD Radeon Vega 8 Graphics", "AMD Radeon RX 6600", "AMD Custom GPU 0405", or older codename strings fell through and their GPU stats were collected as Intel. It now matches Mesa's "for AMD" driver string. * return a 500 instead of raising when go2rtc is unreachable The two go2rtc stream GETs had no exception handling or timeout, so a go2rtc outage raised a connection error with a traceback, and a hung go2rtc held the request open indefinitely. Both now catch request errors, log the cause, and use a timeout. The per-stream probe gets a 35s read timeout because go2rtc probes each source in turn, and one unresponsive source takes about 10s. * retry camera snapshots after a failed load AutoUpdatingCameraImage only scheduled the next frame from onLoad, so a single failed latest.webp request froze the image until the component remounted. It now retries on error, dropping the periodic cache key so the retry requests a fresh URL. * reuse pooled upstream connections for nginx auth subrequests The /auth subrequest proxied to a variable address, which nginx can't pool, and its location-level headers dropped the inherited Connection header so nginx sent Connection: close. Every authenticated request opened and closed its own loopback connection to the API. It now proxies to the frigate_api upstream with an empty Connection header so the keepalive pool is reused.
484 lines
16 KiB
Python
484 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
|
|
|
|
if self.start_time == 0:
|
|
self.start_time = frame_time
|
|
|
|
if self.config.record.enabled:
|
|
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.enabled:
|
|
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"]
|
|
]
|