From 27cd5a07e2ba0c1b825f511ea3eabceac8be4afc Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 19 Apr 2024 07:41:39 -0600 Subject: [PATCH] Save exports to DB and save thumbnail for export --- frigate/api/media.py | 8 +++ frigate/app.py | 2 + frigate/models.py | 1 + frigate/record/export.py | 120 ++++++++++++++++++++++++++++++++++- frigate/review/maintainer.py | 8 ++- 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 5387b2866..f226e3ff4 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -611,6 +611,13 @@ def export_recording(camera_name: str, start_time, end_time): json: dict[str, any] = request.get_json(silent=True) or {} playback_factor = json.get("playback", "realtime") name: Optional[str] = json.get("name") + existing_thumb: Optional[str] = json.get("thumb") + + if len(name) > 256 or len(existing_thumb) > 256: + return make_response( + jsonify({"success": False, "message": "File name is too long."}), + 401, + ) recordings_count = ( Recordings.select() @@ -635,6 +642,7 @@ def export_recording(camera_name: str, start_time, end_time): current_app.frigate_config, camera_name, secure_filename(name.replace(" ", "_")) if name else None, + secure_filename(existing_thumb), int(start_time), int(end_time), ( diff --git a/frigate/app.py b/frigate/app.py index a0722ecf2..0d7346405 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -41,6 +41,7 @@ from frigate.events.maintainer import EventProcessor from frigate.log import log_process, root_configurer from frigate.models import ( Event, + Export, Previews, Recordings, RecordingsToDelete, @@ -320,6 +321,7 @@ class FrigateApp: ) models = [ Event, + Export, Previews, Recordings, RecordingsToDelete, diff --git a/frigate/models.py b/frigate/models.py index 0bec241b0..5400f0b1e 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -84,6 +84,7 @@ class Export(Model): # type: ignore[misc] video_path = CharField(unique=True) thumb_path = CharField(unique=True) + class ReviewSegment(Model): # type: ignore[misc] id = CharField(null=False, primary_key=True, max_length=30) camera = CharField(index=True, max_length=20) diff --git a/frigate/record/export.py b/frigate/record/export.py index 238d7b117..4652c7dea 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -3,18 +3,27 @@ import datetime import logging import os +import random +import shutil +import string import subprocess as sp import threading from enum import Enum from pathlib import Path from frigate.config import FrigateConfig -from frigate.const import EXPORT_DIR, MAX_PLAYLIST_SECONDS +from frigate.const import ( + CACHE_DIR, + CLIPS_DIR, + EXPORT_DIR, + MAX_PLAYLIST_SECONDS, + PREVIEW_FRAME_TYPE, +) from frigate.ffmpeg_presets import ( EncodeTypeEnum, parse_preset_hardware_acceleration_encode, ) -from frigate.models import Recordings +from frigate.models import Export, Previews, Recordings logger = logging.getLogger(__name__) @@ -39,6 +48,7 @@ class RecordingExporter(threading.Thread): config: FrigateConfig, camera: str, name: str, + existing_thumb: str, start_time: int, end_time: int, playback_factor: PlaybackFactorEnum, @@ -47,14 +57,106 @@ class RecordingExporter(threading.Thread): self.config = config self.camera = camera self.user_provided_name = name + self.existing_thumb = existing_thumb self.start_time = start_time self.end_time = end_time self.playback_factor = playback_factor + # ensure export thumb dir + Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) + def get_datetime_from_timestamp(self, timestamp: int) -> str: """Convenience fun to get a simple date time from timestamp.""" return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M") + def save_thumbnail(self, id: str) -> str: + thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp") + + if self.existing_thumb and os.path.isfile(self.existing_thumb): + shutil.copyfile(self.existing_thumb, thumb_path) + else: + if datetime.datetime.fromtimestamp( + self.start_time + ) < datetime.datetime.now().replace(minute=0, second=0): + # has preview mp4 + preview: Previews = ( + Previews.select( + Previews.camera, + Previews.path, + Previews.duration, + Previews.start_time, + Previews.end_time, + ) + .where( + Previews.start_time.between(self.start_time, self.end_time) + | Previews.end_time.between(self.start_time, self.end_time) + | ( + (self.start_time > Previews.start_time) + & (self.end_time < Previews.end_time) + ) + ) + .where(Previews.camera == self.camera) + .limit(1) + .get() + ) + + if not preview: + return "" + + diff = self.start_time - preview.start_time + minutes = int(diff / 60) + seconds = int(diff % 60) + ffmpeg_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + "-ss", + f"00:{minutes}:{seconds}", + "-i", + preview.path, + "-c:v", + "libwebp", + thumb_path, + ] + + process = sp.run( + ffmpeg_cmd, + capture_output=True, + ) + + if process.returncode != 0: + logger.error(process.stderr) + return "" + + else: + # need to generate from existing images + preview_dir = os.path.join(CACHE_DIR, "preview_frames") + file_start = f"preview_{self.camera}" + start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}" + end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}" + selected_preview = None + + for file in sorted(os.listdir(preview_dir)): + if not file.startswith(file_start): + continue + + if file < start_file: + continue + + if file > end_file: + break + + selected_preview = os.path.join(preview_dir, file) + break + + if not selected_preview: + return "" + + shutil.copyfile(selected_preview, thumb_path) + + return thumb_path + def run(self) -> None: logger.debug( f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" @@ -133,4 +235,18 @@ class RecordingExporter(threading.Thread): logger.debug(f"Updating finalized export {file_path}") os.rename(file_path, final_file_path) + export_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + + thumb_path = self.save_thumbnail(export_id) + + Export.insert( + { + Export.id: export_id, + Export.camera: self.camera, + Export.date: self.start_time, + Export.video_path: final_file_path, + Export.thumb_path: thumb_path, + } + ) + logger.debug(f"Finished exporting {file_path}") diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index dc1e5b0f2..5123a7fcd 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -9,6 +9,7 @@ import sys import threading from enum import Enum from multiprocessing.synchronize import Event as MpEvent +from pathlib import Path from typing import Optional import cv2 @@ -64,7 +65,9 @@ class PendingReviewSegment: # thumbnail self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) self.frame_active_count = 0 - self.frame_path = os.path.join(CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.jpg") + self.frame_path = os.path.join( + CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.jpg" + ) def update_frame( self, camera_config: CameraConfig, frame, objects: list[TrackedObject] @@ -138,6 +141,9 @@ class ReviewSegmentMaintainer(threading.Thread): # manual events self.indefinite_events: dict[str, dict[str, any]] = {} + # ensure dirs + Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True) + self.stop_event = stop_event def update_segment(self, segment: PendingReviewSegment) -> None: