Files
frigate/frigate/video/ffmpeg.py
T
Josh HawkinsandGitHub 275e18403e Miscellaneous fixes (#24704)
* remove unreachable FileNotFoundError catches around frame_manager.get

* clear the end time cache entry when a segment is moved

The successful move returned before the end_time_cache pop at the bottom of move_segment, so every kept segment left its entry, keyframes list included, in the cache for the life of the process. Removing the cache file through drop_segment clears the entry with it.

* drop the cache file when the recording destination already exists

When the destination mp4 already existed, move_segment returned without removing the cache file, so the next pass probed and resolved the same segment again, forever. The leftover file is now dropped along with its end time cache entry, the same as the exception path.

* pace restarts of exited record and audio ffmpeg processes

The watchdog loop runs every second, and an exited record or audio ffmpeg was restarted on every pass with no backoff. A camera that refuses connections, such as one rebooting or rejecting credentials, got a reconnect attempt and a dump of ffmpeg's logs every second until it came back. The restart is now gated on can_restart like the stale segment and detect paths.

* don't send a stray newline in the certsync tls probe

The probe piped echo into openssl s_client, which sent the newline to nginx as a request after the handshake, so nginx logged an empty "" 400 line every 60 seconds on any install with TLS enabled. Reading stdin from /dev/null closes the connection right after the handshake. The fingerprint and the failure logging are unchanged.

* require a record expire_interval of at least 1

An expire_interval of 0 or less made the cleanup loop iterate over an empty range, so the cleanup thread exited on startup and expired recordings, temp clips and previews were never removed.

* remove the transcription temp wav when whisper raises

The temp wav was only removed after a successful transcribe call, so every failed transcription left a file in the cache directory.

* don't kill the ipc responder when a described event is gone

handle_update_event_description used Event.get, so a GenAI description that finished after its event was deleted raised DoesNotExist on the inter-process REP thread. That thread died with a reply still owed, and every process waiting on the requestor hung until restart. A missing event is now skipped.

* size the birdseye frame from the snapped canvas shape

The birdseye writer sizes its shared memory with get_canvas_shape, which snaps an unsupported aspect ratio such as 1920x1200 to a supported one, but the tracked object processor read it with the raw config width and height. The sizes never matched, so get returned None on every call and /api/birdseye/latest.jpg returned a 500, which broke the Home Assistant birdseye entity's still image. The reader now uses the same canvas shape, and a missing frame during startup returns an error response instead of raising in cvtColor.

* remove dead disconnect timer from the mse player

The effect that scheduled a delayed disconnect when playback was disabled never ran its body. The connect effect's cleanup already calls onDisconnect, which clears wsRef before the later effect checks it.

* remove partial output when a recording move fails

A remux that failed partway, such as on a full disk, left a partial mp4 at the destination. The next pass saw the destination already existed and dropped the cache file instead of retrying, so the segment was never saved. The failed output is now removed so the next pass retries the move. Tests cover the success, existing destination and failed move paths of move_segment.
2026-10-18 04:50:25 -10:00

770 lines
30 KiB
Python

"""Manages ffmpeg processes for camera frame capture."""
import logging
import queue
import subprocess as sp
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
from frigate.camera import CameraMetrics
from frigate.comms.inter_process import InterProcessRequestor
from frigate.comms.recordings_updater import (
RecordingsDataSubscriber,
RecordingsDataTypeEnum,
)
from frigate.config import CameraConfig, LoggerConfig
from frigate.config.camera.ffmpeg import CameraRoleEnum
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
)
from frigate.const import (
PROCESS_PRIORITY_HIGH,
RECORD_STREAM_TYPES,
ROLE_TO_STREAM_TYPE,
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
STREAM_TYPE_TO_ROLE,
)
from frigate.log import LogPipe
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
from frigate.util.image import (
FrameManager,
SharedMemoryFrameManager,
)
from frigate.util.process import FrigateProcess
logger = logging.getLogger(__name__)
RECORD_GRACE_SECONDS = 90
def capture_frames(
ffmpeg_process: sp.Popen[Any],
config: CameraConfig,
shm_frame_count: int,
frame_index: int,
frame_shape: tuple[int, int],
frame_manager: FrameManager,
frame_queue,
fps: Value,
skipped_fps: Value,
current_frame: Value,
stop_event: MpEvent,
) -> None:
frame_size = frame_shape[0] * frame_shape[1]
frame_rate = EventsPerSecond()
frame_rate.start()
skipped_eps = EventsPerSecond()
skipped_eps.start()
frame_times = frame_manager.frame_times(config.name)
while not stop_event.is_set():
# CameraWatchdog applies enabled updates onto this same CameraConfig
# before it stops ffmpeg. Do not subscribe here: it would be rebuilt per
# ffmpeg restart and strand a pipe in the idle main process config PUB.
if not config.enabled:
logger.debug(f"Stopping capture thread for disabled {config.name}")
break
fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps()
current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name)
# readinto fills the slot for the whole pipe wait, so readers must not
# match it until the frame is complete
frame_times[frame_index] = 0
try:
if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size:
raise EOFError("incomplete frame")
except Exception:
# shutdown has been initiated
if stop_event.is_set():
break
logger.error(f"{config.name}: Unable to read frames from ffmpeg process.")
if ffmpeg_process.poll() is not None:
logger.error(
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
)
break
continue
frame_times[frame_index] = current_frame.value
frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full
try:
# add to the queue
frame_queue.put((frame_name, current_frame.value), False)
except queue.Full:
# nobody was told about this slot, so the next frame reuses it
skipped_eps.update()
continue
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
@dataclass
class RoleStatus:
"""Publishes a role's status when it changes or the resend interval elapses."""
requestor: InterProcessRequestor
topic: str
resend_interval: float
last_status: str | None = None
last_update_time: float = 0.0
def send(self, status: str, now: float) -> None:
"""Publish a changed status or resend it after the configured interval."""
if (
status != self.last_status
or (now - self.last_update_time) >= self.resend_interval
):
self.requestor.send_data(self.topic, status)
self.last_status = status
self.last_update_time = now
class CameraWatchdog(threading.Thread):
def __init__(
self,
config: CameraConfig,
shm_frame_count: int,
frame_queue: Queue,
camera_fps,
skipped_fps,
ffmpeg_pid,
stalls,
reconnects,
detection_frame,
stop_event,
):
threading.Thread.__init__(self)
self.logger = logging.getLogger(f"watchdog.{config.name}")
self.config = config
self.shm_frame_count = shm_frame_count
self.capture_thread = None
self.ffmpeg_detect_process = None
self.logpipe = LogPipe(f"ffmpeg.{self.config.name}.detect")
self.ffmpeg_other_processes: list[dict[str, Any]] = []
self.camera_fps = camera_fps
self.skipped_fps = skipped_fps
self.ffmpeg_pid = ffmpeg_pid
self.frame_queue = frame_queue
self.frame_shape = self.config.frame_shape_yuv
self.frame_size = self.frame_shape[0] * self.frame_shape[1]
self.fps_overflow_count = 0
self.frame_index = 0
self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval
self.reconnect_timestamps = deque()
self.stalls = stalls
self.reconnects = reconnects
self.detection_frame = detection_frame
self.config_subscriber = CameraConfigUpdateSubscriber(
None,
{config.name: config},
[
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.ffmpeg,
CameraConfigUpdateEnum.record,
],
)
self.requestor = InterProcessRequestor()
self.was_enabled = self.config.enabled
self.was_record_enabled_in_config = self.config.record.enabled_in_config
self.was_record_sub_enabled = self.config.record.sub.enabled
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
self.latest_valid_segment_time: dict[str, float] = defaultdict(float)
self.latest_invalid_segment_time: dict[str, float] = defaultdict(float)
self.latest_cache_segment_time: dict[str, float] = defaultdict(float)
self.record_enable_time: datetime | None = None
self.stream_grace_until: dict[str, datetime] = {}
# `valid` segments are published with the segment's start time, so the
# gap between consecutive publishes can reach 2 * segment_time. Pad the
# staleness threshold so it's never tighter than that worst case.
self.record_stale_threshold: dict[str, int] = {
stream_type: max(
120, 2 * get_record_segment_time(self.config, stream_type) + 30
)
for stream_type in RECORD_STREAM_TYPES
}
# the sub stream usually shares its input, and therefore its ffmpeg
# process, with detect, so it isn't in ffmpeg_other_processes and needs
# its own staleness check
self.detect_process_records_sub = False
# Stall tracking (based on last processed frame)
self._stall_timestamps: deque[float] = deque()
self._stall_active: bool = False
# Status caching to reduce message volume
self.detect_status = self._role_status("detect")
self.record_status = {
stream_type: self._role_status(role)
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
}
def _role_status(self, role: str) -> RoleStatus:
return RoleStatus(
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
)
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
"""Send offline status for each role of a restarted ffmpeg process."""
for role in roles:
# record roles go through the status cache so the recovery to
# online is published once the stream is healthy again
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
if stream_type is not None:
self.record_status[stream_type].send("offline", now)
else:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
)
def _reset_segment_times(self) -> None:
self.latest_valid_segment_time.clear()
self.latest_invalid_segment_time.clear()
self.latest_cache_segment_time.clear()
self.stream_grace_until.clear()
def _grant_restart_grace(self, stream_types: list[str], now_utc: datetime) -> None:
for stream_type in stream_types:
self.stream_grace_until[stream_type] = now_utc + timedelta(
seconds=RECORD_GRACE_SECONDS
)
def _stream_staleness(self, stream_type: str, now_utc: datetime) -> str | None:
"""Return why the stream's segments are stale, or None if they're healthy."""
# ffmpeg needs time to create a first segment after recording is
# enabled and after a restart, per stream
in_grace_period = (
self.record_enable_time is not None
and (now_utc - self.record_enable_time)
< timedelta(seconds=RECORD_GRACE_SECONDS)
) or now_utc < self.stream_grace_until.get(stream_type, now_utc)
if in_grace_period:
return None
latest_cache = self.latest_cache_segment_time[stream_type]
latest_valid = self.latest_valid_segment_time[stream_type]
latest_invalid = self.latest_invalid_segment_time[stream_type]
def as_dt(timestamp: float) -> datetime:
if timestamp > 0:
return datetime.fromtimestamp(timestamp, tz=UTC)
return now_utc - timedelta(seconds=1)
stale_window = timedelta(seconds=self.record_stale_threshold[stream_type])
if now_utc > (as_dt(latest_cache) + stale_window):
return "No new recording segments were created"
if now_utc > (as_dt(latest_valid) + stale_window):
return "No new valid recording segments were created"
if (
latest_invalid > 0
and now_utc > (as_dt(latest_invalid) + stale_window)
and latest_valid <= latest_invalid
):
return "No valid segments created since last invalid segment"
return None
def _recorded_streams(self, roles: list[Any]) -> list[str]:
"""Record stream types the given roles cover that are currently recording."""
return [
stream_type
for role, stream_type in ROLE_TO_STREAM_TYPE.items()
if role in roles and self.config.record.stream_enabled(stream_type)
]
def _check_config_updates(self) -> dict[str, list[str]]:
"""Check for config updates and return the update dict."""
return self.config_subscriber.check_for_updates()
def _update_enabled_state(self) -> bool:
"""Fetch the latest config and update enabled state."""
self._check_config_updates()
return self.config.enabled
def reset_capture_thread(self, terminate: bool = True) -> None:
if terminate:
# The capture thread keeps draining stdout, so wait() is enough here.
# communicate() would also buffer every frame ffmpeg writes before it
# exits, which can be many duplicates after a stall.
self.ffmpeg_detect_process.terminate()
try:
self.logger.info("Waiting for ffmpeg to exit gracefully...")
self.ffmpeg_detect_process.wait(timeout=30)
except sp.TimeoutExpired:
self.logger.info("FFmpeg did not exit. Force killing...")
self.ffmpeg_detect_process.kill()
self.ffmpeg_detect_process.wait()
# Update reconnects
now = datetime.now().timestamp()
self.reconnect_timestamps.append(now)
while self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600:
self.reconnect_timestamps.popleft()
if self.reconnects:
self.reconnects.value = len(self.reconnect_timestamps)
# Wait for old capture thread to fully exit before starting a new one
if self.capture_thread is not None and self.capture_thread.is_alive():
self.logger.info("Waiting for capture thread to exit...")
self.capture_thread.join(timeout=5)
if self.capture_thread.is_alive():
self.logger.warning(
f"Capture thread for {self.config.name} did not exit in time"
)
self.logpipe.dump()
self.logger.info("Restarting ffmpeg...")
self.start_ffmpeg_detect()
# this process produces the sub stream's segments too, so it gets the
# same startup grace however the reset was triggered
if self.detect_process_records_sub:
self._grant_restart_grace([STREAM_TYPE_SUB], datetime.now().astimezone(UTC))
def run(self) -> None:
if self._update_enabled_state():
self.start_all_ffmpeg()
# If recording is enabled at startup, set the grace period timer
if self.config.record.enabled:
self.record_enable_time = datetime.now().astimezone(UTC)
time.sleep(self.sleeptime)
last_restart_time = datetime.now().timestamp()
# 1 second watchdog loop
while not self.stop_event.wait(1):
updates = self._check_config_updates()
# Handle ffmpeg config changes by restarting all ffmpeg processes
if "ffmpeg" in updates and self.config.enabled:
self.logger.debug(
"FFmpeg config updated for %s, restarting ffmpeg processes",
self.config.name,
)
self.stop_all_ffmpeg()
self.start_all_ffmpeg()
self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp()
continue
enabled = self.config.enabled
if enabled != self.was_enabled:
if enabled:
self.logger.debug(f"Enabling camera {self.config.name}")
self.start_all_ffmpeg()
# reset all timestamps and record the enable time for grace period
self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC)
else:
self.logger.debug(f"Disabling camera {self.config.name}")
self.stop_all_ffmpeg()
self.record_enable_time = None
# update camera status
now = datetime.now().timestamp()
self.detect_status.send("disabled", now)
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
# cameras without a sub stream never get a record_sub topic
if self.config.record.sub.enabled:
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
self.was_enabled = enabled
continue
record_enabled_in_config = self.config.record.enabled_in_config
if record_enabled_in_config != self.was_record_enabled_in_config:
if record_enabled_in_config and enabled:
self.logger.debug(
f"Record enabled in config for {self.config.name}, restarting ffmpeg"
)
self.stop_all_ffmpeg()
self.start_all_ffmpeg()
self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp()
self.was_record_enabled_in_config = record_enabled_in_config
continue
record_sub_enabled = self.config.record.sub.enabled
if record_sub_enabled != self.was_record_sub_enabled:
# adding and removing the record_sub output both require a
# restart, unlike the main record toggle
if record_enabled_in_config and enabled:
self.logger.debug(
f"Sub stream recording toggled in config for {self.config.name}, restarting ffmpeg"
)
self.stop_all_ffmpeg()
self.start_all_ffmpeg()
self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp()
self.was_record_sub_enabled = record_sub_enabled
continue
if not enabled:
continue
while True:
update = self.segment_subscriber.check_for_update(timeout=0)
if update == (None, None):
break
raw_topic, payload = update
if raw_topic and payload:
topic = str(raw_topic)
camera, stream_type, segment_time, _ = payload
if camera != self.config.name:
continue
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
self.logger.warning(
f"Invalid recording segment detected for {camera} ({stream_type}) at {segment_time}"
)
self.latest_invalid_segment_time[stream_type] = segment_time
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
self.logger.debug(
f"Latest valid recording segment time on {camera} ({stream_type}): {segment_time}"
)
self.latest_valid_segment_time[stream_type] = segment_time
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
self.latest_cache_segment_time[stream_type] = (
segment_time if segment_time is not None else 0
)
now = datetime.now().timestamp()
# Check if enough time has passed to allow ffmpeg restart (backoff pacing)
time_since_last_restart = now - last_restart_time
can_restart = time_since_last_restart >= self.sleeptime
if not self.capture_thread.is_alive():
self.detect_status.send("offline", now)
self.camera_fps.value = 0
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
)
if can_restart:
self.reset_capture_thread(terminate=False)
last_restart_time = now
elif self.camera_fps.value >= (self.config.detect.fps + 10):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
self.detect_status.send("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
f"{self.config.name} exceeded fps limit. Exiting ffmpeg..."
)
if can_restart:
self.reset_capture_thread()
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self.detect_status.send("offline", now)
self.camera_fps.value = 0
self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
)
if can_restart:
self.reset_capture_thread()
last_restart_time = now
else:
# process is running normally
self.detect_status.send("online", now)
self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes:
poll = p["process"].poll()
recorded_streams = self._recorded_streams(p["roles"])
if recorded_streams:
now_utc = datetime.now().astimezone(UTC)
# ensure segments are still being created and that they have
# valid video data. each stream is tracked separately so a
# healthy one can't mask a stalled one.
stale_stream = None
stale_reason = None
for stream_type in recorded_streams:
stale_reason = self._stream_staleness(stream_type, now_utc)
if stale_reason is not None:
stale_stream = stream_type
break
if stale_stream is not None and can_restart:
self.logger.error(
f"{stale_reason} for {self.config.name} ({stale_stream}) in the last {self.record_stale_threshold[stale_stream]}s. Restarting the ffmpeg record process..."
)
p["process"] = start_or_restart_ffmpeg(
p["cmd"],
self.logger,
p["logpipe"],
ffmpeg_process=p["process"],
)
self._send_roles_offline(p["roles"], now)
self._grant_restart_grace(recorded_streams, now_utc)
last_restart_time = now
continue
elif stale_stream is None:
if poll is None:
for stream_type in recorded_streams:
self.record_status[stream_type].send("online", now)
p["latest_segment_time"] = max(
self.latest_cache_segment_time[stream_type]
for stream_type in recorded_streams
)
if poll is None:
continue
self._send_roles_offline(p["roles"], now)
if can_restart:
p["process"] = start_or_restart_ffmpeg(
p["cmd"],
self.logger,
p["logpipe"],
ffmpeg_process=p["process"],
)
last_restart_time = now
if self.detect_process_records_sub and self.config.record.stream_enabled(
STREAM_TYPE_SUB
):
now_utc = datetime.now().astimezone(UTC)
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
if self.detect_status.last_status == "offline":
# the sub stream is down whenever the detect process is
self.record_status[STREAM_TYPE_SUB].send("offline", now)
elif stale_reason is None:
self.record_status[STREAM_TYPE_SUB].send("online", now)
elif can_restart:
self.logger.error(
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
)
self.record_status[STREAM_TYPE_SUB].send("offline", now)
self.reset_capture_thread()
last_restart_time = now
# Prune expired reconnect timestamps
now = datetime.now().timestamp()
while (
self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600
):
self.reconnect_timestamps.popleft()
if self.reconnects:
self.reconnects.value = len(self.reconnect_timestamps)
# Update stall metrics based on last processed frame timestamp
processed_ts = (
float(self.detection_frame.value) if self.detection_frame else 0.0
)
if processed_ts > 0:
delta = now - processed_ts
observed_fps = (
self.camera_fps.value
if self.camera_fps.value > 0
else self.config.detect.fps
)
interval = 1.0 / max(observed_fps, 0.1)
stall_threshold = max(2.0 * interval, 2.0)
if delta > stall_threshold:
if not self._stall_active:
self._stall_timestamps.append(now)
self._stall_active = True
else:
self._stall_active = False
while self._stall_timestamps and self._stall_timestamps[0] < now - 3600:
self._stall_timestamps.popleft()
if self.stalls:
self.stalls.value = len(self._stall_timestamps)
self.stop_all_ffmpeg()
self.logpipe.close()
self.config_subscriber.stop()
self.segment_subscriber.stop()
def start_ffmpeg_detect(self):
detect_cmd = [c for c in self.config.ffmpeg_cmds if "detect" in c["roles"]][0]
ffmpeg_cmd = detect_cmd["cmd"]
self.detect_process_records_sub = "record_sub" in detect_cmd["roles"]
self.ffmpeg_detect_process = start_or_restart_ffmpeg(
ffmpeg_cmd, self.logger, self.logpipe, self.frame_size
)
self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid
self.capture_thread = CameraCaptureRunner(
self.config,
self.shm_frame_count,
self.frame_index,
self.ffmpeg_detect_process,
self.frame_shape,
self.frame_queue,
self.camera_fps,
self.skipped_fps,
self.stop_event,
)
self.capture_thread.start()
def start_all_ffmpeg(self):
"""Start all ffmpeg processes (detection and others)."""
logger.debug(f"Starting all ffmpeg processes for {self.config.name}")
self.start_ffmpeg_detect()
for c in self.config.ffmpeg_cmds:
if "detect" in c["roles"]:
continue
logpipe = LogPipe(
f"ffmpeg.{self.config.name}.{'_'.join(sorted(c['roles']))}"
)
self.ffmpeg_other_processes.append(
{
"cmd": c["cmd"],
"roles": c["roles"],
"logpipe": logpipe,
"process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
}
)
def stop_all_ffmpeg(self):
"""Stop all ffmpeg processes (detection and others)."""
logger.debug(f"Stopping all ffmpeg processes for {self.config.name}")
if self.capture_thread is not None and self.capture_thread.is_alive():
self.capture_thread.join(timeout=5)
if self.capture_thread.is_alive():
self.logger.warning(
f"Capture thread for {self.config.name} did not stop gracefully."
)
if self.ffmpeg_detect_process is not None:
stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
self.ffmpeg_detect_process = None
for p in self.ffmpeg_other_processes[:]:
if p["process"] is not None:
stop_ffmpeg(p["process"], self.logger)
p["logpipe"].close()
self.ffmpeg_other_processes.clear()
class CameraCaptureRunner(threading.Thread):
def __init__(
self,
config: CameraConfig,
shm_frame_count: int,
frame_index: int,
ffmpeg_process,
frame_shape: tuple[int, int],
frame_queue: Queue,
fps: Value,
skipped_fps: Value,
stop_event: MpEvent,
):
threading.Thread.__init__(self)
self.name = f"capture:{config.name}"
self.config = config
self.shm_frame_count = shm_frame_count
self.frame_index = frame_index
self.frame_shape = frame_shape
self.frame_queue = frame_queue
self.fps = fps
self.stop_event = stop_event
self.skipped_fps = skipped_fps
self.frame_manager = SharedMemoryFrameManager()
self.ffmpeg_process = ffmpeg_process
self.current_frame = Value("d", 0.0)
self.last_frame = 0
def run(self):
capture_frames(
self.ffmpeg_process,
self.config,
self.shm_frame_count,
self.frame_index,
self.frame_shape,
self.frame_manager,
self.frame_queue,
self.fps,
self.skipped_fps,
self.current_frame,
self.stop_event,
)
class CameraCapture(FrigateProcess):
def __init__(
self,
config: CameraConfig,
shm_frame_count: int,
camera_metrics: CameraMetrics,
stop_event: MpEvent,
log_config: LoggerConfig | None = None,
) -> None:
super().__init__(
stop_event,
PROCESS_PRIORITY_HIGH,
name=f"frigate.capture:{config.name}",
daemon=True,
)
self.config = config
self.shm_frame_count = shm_frame_count
self.camera_metrics = camera_metrics
self.log_config = log_config
def run(self) -> None:
self.pre_run_setup(self.log_config)
camera_watchdog = CameraWatchdog(
self.config,
self.shm_frame_count,
self.camera_metrics.frame_queue,
self.camera_metrics.camera_fps,
self.camera_metrics.skipped_fps,
self.camera_metrics.ffmpeg_pid,
self.camera_metrics.stalls_last_hour,
self.camera_metrics.reconnects_last_hour,
self.camera_metrics.detection_frame,
self.stop_event,
)
camera_watchdog.start()
camera_watchdog.join()