From 18824830fde88a50873cba5d1f0dfeaeba970e2b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 23 Oct 2024 07:36:52 -0600 Subject: [PATCH] Export preview via api (#14535) * Break out recording to separate function * Implement preview exporting * Formatting --- frigate/api/export.py | 67 ++++++++++++++------ frigate/record/export.py | 128 +++++++++++++++++++++++++++++++-------- 2 files changed, 152 insertions(+), 43 deletions(-) diff --git a/frigate/api/export.py b/frigate/api/export.py index d697709c5..c3299a4b0 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -13,8 +13,12 @@ from peewee import DoesNotExist from frigate.api.defs.tags import Tags from frigate.const import EXPORT_DIR -from frigate.models import Export, Recordings -from frigate.record.export import PlaybackFactorEnum, RecordingExporter +from frigate.models import Export, Previews, Recordings +from frigate.record.export import ( + PlaybackFactorEnum, + PlaybackSourceEnum, + RecordingExporter, +) logger = logging.getLogger(__name__) @@ -45,6 +49,7 @@ def export_recording( json: dict[str, any] = body or {} playback_factor = json.get("playback", "realtime") + playback_source = json.get("source", "recordings") friendly_name: Optional[str] = json.get("name") if len(friendly_name or "") > 256: @@ -55,25 +60,48 @@ def export_recording( existing_image = json.get("image_path") - recordings_count = ( - Recordings.select() - .where( - Recordings.start_time.between(start_time, end_time) - | Recordings.end_time.between(start_time, end_time) - | ((start_time > Recordings.start_time) & (end_time < Recordings.end_time)) + if playback_source == "recordings": + recordings_count = ( + Recordings.select() + .where( + Recordings.start_time.between(start_time, end_time) + | Recordings.end_time.between(start_time, end_time) + | ( + (start_time > Recordings.start_time) + & (end_time < Recordings.end_time) + ) + ) + .where(Recordings.camera == camera_name) + .count() ) - .where(Recordings.camera == camera_name) - .count() - ) - if recordings_count <= 0: - return JSONResponse( - content=( - {"success": False, "message": "No recordings found for time range"} - ), - status_code=400, + if recordings_count <= 0: + return JSONResponse( + content=( + {"success": False, "message": "No recordings found for time range"} + ), + status_code=400, + ) + else: + previews_count = ( + Previews.select() + .where( + Previews.start_time.between(start_time, end_time) + | Previews.end_time.between(start_time, end_time) + | ((start_time > Previews.start_time) & (end_time < Previews.end_time)) + ) + .where(Previews.camera == camera_name) + .count() ) + if previews_count <= 0: + return JSONResponse( + content=( + {"success": False, "message": "No previews found for time range"} + ), + status_code=400, + ) + export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}" exporter = RecordingExporter( request.app.frigate_config, @@ -88,6 +116,11 @@ def export_recording( if playback_factor in PlaybackFactorEnum.__members__.values() else PlaybackFactorEnum.realtime ), + ( + PlaybackSourceEnum[playback_source] + if playback_source in PlaybackSourceEnum.__members__.values() + else PlaybackSourceEnum.recordings + ), ) exporter.start() return JSONResponse( diff --git a/frigate/record/export.py b/frigate/record/export.py index 395da79ea..325f08419 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -43,6 +43,11 @@ class PlaybackFactorEnum(str, Enum): timelapse_25x = "timelapse_25x" +class PlaybackSourceEnum(str, Enum): + recordings = "recordings" + preview = "preview" + + class RecordingExporter(threading.Thread): """Exports a specific set of recordings for a camera to storage as a single file.""" @@ -56,6 +61,7 @@ class RecordingExporter(threading.Thread): start_time: int, end_time: int, playback_factor: PlaybackFactorEnum, + playback_source: PlaybackSourceEnum, ) -> None: super().__init__() self.config = config @@ -66,6 +72,7 @@ class RecordingExporter(threading.Thread): self.start_time = start_time self.end_time = end_time self.playback_factor = playback_factor + self.playback_source = playback_source # ensure export thumb dir Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) @@ -170,30 +177,7 @@ class RecordingExporter(threading.Thread): return thumb_path - def run(self) -> None: - logger.debug( - f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" - ) - export_name = ( - self.user_provided_name - or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}" - ) - video_path = f"{EXPORT_DIR}/{self.export_id}.mp4" - - thumb_path = self.save_thumbnail(self.export_id) - - Export.insert( - { - Export.id: self.export_id, - Export.camera: self.camera, - Export.name: export_name, - Export.date: self.start_time, - Export.video_path: video_path, - Export.thumb_path: thumb_path, - Export.in_progress: True, - } - ).execute() - + def get_record_export_command(self, video_path: str) -> list[str]: if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8" ffmpeg_input = ( @@ -204,7 +188,10 @@ class RecordingExporter(threading.Thread): # get full set of recordings export_recordings = ( - Recordings.select() + Recordings.select( + Recordings.start_time, + Recordings.end_time, + ) .where( Recordings.start_time.between(self.start_time, self.end_time) | Recordings.end_time.between(self.start_time, self.end_time) @@ -229,6 +216,65 @@ class RecordingExporter(threading.Thread): ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin" + if self.playback_factor == PlaybackFactorEnum.realtime: + ffmpeg_cmd = ( + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}" + ).split(" ") + elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: + ffmpeg_cmd = ( + parse_preset_hardware_acceleration_encode( + self.config.ffmpeg.ffmpeg_path, + self.config.ffmpeg.hwaccel_args, + f"-an {ffmpeg_input}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", + EncodeTypeEnum.timelapse, + ) + ).split(" ") + + return ffmpeg_cmd, playlist_lines + + def get_preview_export_command(self, video_path: str) -> list[str]: + playlist_lines = [] + + # get full set of previews + export_previews = ( + Previews.select( + Previews.path, + 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) + .order_by(Previews.start_time.asc()) + .namedtuples() + .iterator() + ) + + preview: Previews + for preview in export_previews: + playlist_lines.append(f"file '{preview.path}'") + + if preview.start_time < self.start_time: + playlist_lines.append( + f"inpoint {int(self.start_time - preview.start_time)}" + ) + + if preview.end_time > self.end_time: + playlist_lines.append( + f"outpoint {int(preview.end_time - self.end_time)}" + ) + + ffmpeg_input = ( + "-y -protocol_whitelist pipe,file,tcp -f concat -safe 0 -i /dev/stdin" + ) + if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}" @@ -244,6 +290,36 @@ class RecordingExporter(threading.Thread): ) ).split(" ") + return ffmpeg_cmd, playlist_lines + + def run(self) -> None: + logger.debug( + f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" + ) + export_name = ( + self.user_provided_name + or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}" + ) + video_path = f"{EXPORT_DIR}/{self.export_id}.mp4" + thumb_path = self.save_thumbnail(self.export_id) + + Export.insert( + { + Export.id: self.export_id, + Export.camera: self.camera, + Export.name: export_name, + Export.date: self.start_time, + Export.video_path: video_path, + Export.thumb_path: thumb_path, + Export.in_progress: True, + } + ).execute() + + if self.playback_source == PlaybackSourceEnum.recordings: + ffmpeg_cmd, playlist_lines = self.get_record_export_command(video_path) + else: + ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path) + p = sp.run( ffmpeg_cmd, input="\n".join(playlist_lines), @@ -254,7 +330,7 @@ class RecordingExporter(threading.Thread): if p.returncode != 0: logger.error( - f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" + f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}" ) logger.error(p.stderr) Path(video_path).unlink(missing_ok=True)