From c2d09f4029f7bd63ed2383370bf3e0304002ebda Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 15 Jan 2026 10:10:20 -0700 Subject: [PATCH] refactor time lapse creation to be a separate API call with ability to pass arbitrary ffmpeg args --- .../defs/request/export_recordings_body.py | 32 +++- frigate/api/export.py | 143 +++++++++++++++++- frigate/config/camera/record.py | 5 - frigate/record/export.py | 40 +++-- frigate/util/config.py | 24 ++- 5 files changed, 202 insertions(+), 42 deletions(-) diff --git a/frigate/api/defs/request/export_recordings_body.py b/frigate/api/defs/request/export_recordings_body.py index 841466982..82105d64b 100644 --- a/frigate/api/defs/request/export_recordings_body.py +++ b/frigate/api/defs/request/export_recordings_body.py @@ -3,16 +3,10 @@ from typing import Optional, Union from pydantic import BaseModel, Field from pydantic.json_schema import SkipJsonSchema -from frigate.record.export import ( - PlaybackFactorEnum, - PlaybackSourceEnum, -) +from frigate.record.export import PlaybackSourceEnum class ExportRecordingsBody(BaseModel): - playback: PlaybackFactorEnum = Field( - default=PlaybackFactorEnum.realtime, title="Playback factor" - ) source: PlaybackSourceEnum = Field( default=PlaybackSourceEnum.recordings, title="Playback source" ) @@ -24,3 +18,27 @@ class ExportRecordingsBody(BaseModel): max_length=30, description="ID of the export case to assign this export to", ) + + +class ExportRecordingsCustomBody(BaseModel): + source: PlaybackSourceEnum = Field( + default=PlaybackSourceEnum.recordings, title="Playback source" + ) + name: str = Field(title="Friendly name", default=None, max_length=256) + image_path: Union[str, SkipJsonSchema[None]] = None + export_case_id: Optional[str] = Field( + default=None, + title="Export case ID", + max_length=30, + description="ID of the export case to assign this export to", + ) + ffmpeg_input_args: Optional[str] = Field( + default=None, + title="FFmpeg input arguments", + description="Custom FFmpeg input arguments. If not provided, defaults to timelapse input args.", + ) + ffmpeg_output_args: Optional[str] = Field( + default=None, + title="FFmpeg output arguments", + description="Custom FFmpeg output arguments. If not provided, defaults to timelapse output args.", + ) diff --git a/frigate/api/export.py b/frigate/api/export.py index c2cf66a34..cc01e22eb 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -24,7 +24,10 @@ from frigate.api.defs.request.export_case_body import ( ExportCaseCreateBody, ExportCaseUpdateBody, ) -from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody +from frigate.api.defs.request.export_recordings_body import ( + ExportRecordingsBody, + ExportRecordingsCustomBody, +) from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.response.export_case_response import ( ExportCaseModel, @@ -40,7 +43,7 @@ from frigate.api.defs.tags import Tags from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.models import Export, ExportCase, Previews, Recordings from frigate.record.export import ( - PlaybackFactorEnum, + DEFAULT_TIME_LAPSE_FFMPEG_ARGS, PlaybackSourceEnum, RecordingExporter, ) @@ -262,7 +265,6 @@ def export_recording( status_code=404, ) - playback_factor = body.playback playback_source = body.source friendly_name = body.name existing_image = sanitize_filepath(body.image_path) if body.image_path else None @@ -335,11 +337,6 @@ def export_recording( existing_image, int(start_time), int(end_time), - ( - PlaybackFactorEnum[playback_factor] - if playback_factor in PlaybackFactorEnum.__members__.values() - else PlaybackFactorEnum.realtime - ), ( PlaybackSourceEnum[playback_source] if playback_source in PlaybackSourceEnum.__members__.values() @@ -456,6 +453,136 @@ async def export_delete(event_id: str, request: Request): ) +@router.post( + "/export/custom/{camera_name}/start/{start_time}/end/{end_time}", + response_model=StartExportResponse, + dependencies=[Depends(require_camera_access)], + summary="Start custom recording export", + description="""Starts an export of a recording for the specified time range using custom FFmpeg arguments. + The export can be from recordings or preview footage. Returns the export ID if + successful, or an error message if the camera is invalid or no recordings/previews + are found for the time range. If ffmpeg_input_args and ffmpeg_output_args are not provided, + defaults to timelapse export settings.""", +) +def export_recording_custom( + request: Request, + camera_name: str, + start_time: float, + end_time: float, + body: ExportRecordingsCustomBody, +): + if not camera_name or not request.app.frigate_config.cameras.get(camera_name): + return JSONResponse( + content=( + {"success": False, "message": f"{camera_name} is not a valid camera."} + ), + status_code=404, + ) + + playback_source = body.source + friendly_name = body.name + existing_image = sanitize_filepath(body.image_path) if body.image_path else None + ffmpeg_input_args = body.ffmpeg_input_args + ffmpeg_output_args = body.ffmpeg_output_args + + export_case_id = body.export_case_id + if export_case_id is not None: + try: + ExportCase.get(ExportCase.id == export_case_id) + except DoesNotExist: + return JSONResponse( + content={"success": False, "message": "Export case not found"}, + status_code=404, + ) + + # Ensure that existing_image is a valid path + if existing_image and not existing_image.startswith(CLIPS_DIR): + return JSONResponse( + content=({"success": False, "message": "Invalid image path"}), + status_code=400, + ) + + 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() + ) + + 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 not is_current_hour(start_time) and 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))}" + + # Set default values if not provided (timelapse defaults) + if ffmpeg_input_args is None: + ffmpeg_input_args = "" + + if ffmpeg_output_args is None: + ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS + + exporter = RecordingExporter( + request.app.frigate_config, + export_id, + camera_name, + friendly_name, + existing_image, + int(start_time), + int(end_time), + ( + PlaybackSourceEnum[playback_source] + if playback_source in PlaybackSourceEnum.__members__.values() + else PlaybackSourceEnum.recordings + ), + export_case_id, + ffmpeg_input_args, + ffmpeg_output_args, + ) + exporter.start() + return JSONResponse( + content=( + { + "success": True, + "message": "Starting export of recording.", + "export_id": export_id, + } + ), + status_code=200, + ) + + @router.get( "/exports/{export_id}", response_model=ExportModel, diff --git a/frigate/config/camera/record.py b/frigate/config/camera/record.py index 21816523a..fe24cf522 100644 --- a/frigate/config/camera/record.py +++ b/frigate/config/camera/record.py @@ -19,8 +19,6 @@ __all__ = [ "RetainModeEnum", ] -DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" - class RecordRetainConfig(FrigateBaseModel): days: float = Field(default=0, ge=0, title="Default retention period.") @@ -67,9 +65,6 @@ class RecordPreviewConfig(FrigateBaseModel): class RecordExportConfig(FrigateBaseModel): - timelapse_args: str = Field( - default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args" - ) hwaccel_args: Union[str, list[str]] = Field( default="auto", title="Export-specific FFmpeg hardware acceleration arguments." ) diff --git a/frigate/record/export.py b/frigate/record/export.py index 9a8b5dbdb..cf38f1260 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -33,6 +33,7 @@ from frigate.util.time import is_current_hour logger = logging.getLogger(__name__) +DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey" @@ -40,11 +41,6 @@ def lower_priority(): os.nice(PROCESS_PRIORITY_LOW) -class PlaybackFactorEnum(str, Enum): - realtime = "realtime" - timelapse_25x = "timelapse_25x" - - class PlaybackSourceEnum(str, Enum): recordings = "recordings" preview = "preview" @@ -62,9 +58,10 @@ class RecordingExporter(threading.Thread): image: Optional[str], start_time: int, end_time: int, - playback_factor: PlaybackFactorEnum, playback_source: PlaybackSourceEnum, export_case_id: Optional[str] = None, + ffmpeg_input_args: Optional[str] = None, + ffmpeg_output_args: Optional[str] = None, ) -> None: super().__init__() self.config = config @@ -74,9 +71,10 @@ class RecordingExporter(threading.Thread): self.user_provided_image = image self.start_time = start_time self.end_time = end_time - self.playback_factor = playback_factor self.playback_source = playback_source self.export_case_id = export_case_id + self.ffmpeg_input_args = ffmpeg_input_args + self.ffmpeg_output_args = ffmpeg_output_args # ensure export thumb dir Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) @@ -220,20 +218,20 @@ 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" - ).split(" ") - elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: + if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None: ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.ffmpeg_path, self.config.cameras[self.camera].record.export.hwaccel_args, - f"-an {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", + f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(), + f"{self.ffmpeg_output_args} -movflags +faststart".strip(), EncodeTypeEnum.timelapse, ) ).split(" ") + else: + ffmpeg_cmd = ( + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart" + ).split(" ") # add metadata title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" @@ -311,20 +309,20 @@ class RecordingExporter(threading.Thread): "-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} {codec} -movflags +faststart {video_path}" - ).split(" ") - elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: + if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None: ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.ffmpeg_path, self.config.cameras[self.camera].record.export.hwaccel_args, - f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", + f"{self.ffmpeg_input_args} {TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}".strip(), + f"{self.ffmpeg_output_args} -movflags +faststart {video_path}".strip(), EncodeTypeEnum.timelapse, ) ).split(" ") + else: + ffmpeg_cmd = ( + f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}" + ).split(" ") # add metadata title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" diff --git a/frigate/util/config.py b/frigate/util/config.py index b9e3fccb8..1af5c8e4e 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -442,13 +442,35 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] if new_config.get("record", {}).get("sync_recordings") is not None: del new_config["record"]["sync_recordings"] - # Remove deprecated sync_recordings from camera-specific record configs + # Remove deprecated timelapse_args from global record export config + if new_config.get("record", {}).get("export", {}).get("timelapse_args") is not None: + del new_config["record"]["export"]["timelapse_args"] + # Remove export section if empty + if not new_config.get("record", {}).get("export"): + del new_config["record"]["export"] + # Remove record section if empty + if not new_config.get("record"): + del new_config["record"] + + # Remove deprecated sync_recordings and timelapse_args from camera-specific record configs for name, camera in config.get("cameras", {}).items(): camera_config: dict[str, dict[str, Any]] = camera.copy() if camera_config.get("record", {}).get("sync_recordings") is not None: del camera_config["record"]["sync_recordings"] + if ( + camera_config.get("record", {}).get("export", {}).get("timelapse_args") + is not None + ): + del camera_config["record"]["export"]["timelapse_args"] + # Remove export section if empty + if not camera_config.get("record", {}).get("export"): + del camera_config["record"]["export"] + # Remove record section if empty + if not camera_config.get("record"): + del camera_config["record"] + new_config["cameras"][name] = camera_config new_config["version"] = "0.18-0"