refactor time lapse creation to be a separate API call with ability to pass arbitrary ffmpeg args

This commit is contained in:
Nicolas Mowen 2026-01-15 10:10:20 -07:00
parent d9f8e603c9
commit c2d09f4029
5 changed files with 202 additions and 42 deletions

View File

@ -3,16 +3,10 @@ from typing import Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic.json_schema import SkipJsonSchema from pydantic.json_schema import SkipJsonSchema
from frigate.record.export import ( from frigate.record.export import PlaybackSourceEnum
PlaybackFactorEnum,
PlaybackSourceEnum,
)
class ExportRecordingsBody(BaseModel): class ExportRecordingsBody(BaseModel):
playback: PlaybackFactorEnum = Field(
default=PlaybackFactorEnum.realtime, title="Playback factor"
)
source: PlaybackSourceEnum = Field( source: PlaybackSourceEnum = Field(
default=PlaybackSourceEnum.recordings, title="Playback source" default=PlaybackSourceEnum.recordings, title="Playback source"
) )
@ -24,3 +18,27 @@ class ExportRecordingsBody(BaseModel):
max_length=30, max_length=30,
description="ID of the export case to assign this export to", 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.",
)

View File

@ -24,7 +24,10 @@ from frigate.api.defs.request.export_case_body import (
ExportCaseCreateBody, ExportCaseCreateBody,
ExportCaseUpdateBody, 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.request.export_rename_body import ExportRenameBody
from frigate.api.defs.response.export_case_response import ( from frigate.api.defs.response.export_case_response import (
ExportCaseModel, ExportCaseModel,
@ -40,7 +43,7 @@ from frigate.api.defs.tags import Tags
from frigate.const import CLIPS_DIR, EXPORT_DIR from frigate.const import CLIPS_DIR, EXPORT_DIR
from frigate.models import Export, ExportCase, Previews, Recordings from frigate.models import Export, ExportCase, Previews, Recordings
from frigate.record.export import ( from frigate.record.export import (
PlaybackFactorEnum, DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
PlaybackSourceEnum, PlaybackSourceEnum,
RecordingExporter, RecordingExporter,
) )
@ -262,7 +265,6 @@ def export_recording(
status_code=404, status_code=404,
) )
playback_factor = body.playback
playback_source = body.source playback_source = body.source
friendly_name = body.name friendly_name = body.name
existing_image = sanitize_filepath(body.image_path) if body.image_path else None existing_image = sanitize_filepath(body.image_path) if body.image_path else None
@ -335,11 +337,6 @@ def export_recording(
existing_image, existing_image,
int(start_time), int(start_time),
int(end_time), int(end_time),
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
( (
PlaybackSourceEnum[playback_source] PlaybackSourceEnum[playback_source]
if playback_source in PlaybackSourceEnum.__members__.values() 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( @router.get(
"/exports/{export_id}", "/exports/{export_id}",
response_model=ExportModel, response_model=ExportModel,

View File

@ -19,8 +19,6 @@ __all__ = [
"RetainModeEnum", "RetainModeEnum",
] ]
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
class RecordRetainConfig(FrigateBaseModel): class RecordRetainConfig(FrigateBaseModel):
days: float = Field(default=0, ge=0, title="Default retention period.") days: float = Field(default=0, ge=0, title="Default retention period.")
@ -67,9 +65,6 @@ class RecordPreviewConfig(FrigateBaseModel):
class RecordExportConfig(FrigateBaseModel): class RecordExportConfig(FrigateBaseModel):
timelapse_args: str = Field(
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
)
hwaccel_args: Union[str, list[str]] = Field( hwaccel_args: Union[str, list[str]] = Field(
default="auto", title="Export-specific FFmpeg hardware acceleration arguments." default="auto", title="Export-specific FFmpeg hardware acceleration arguments."
) )

View File

@ -33,6 +33,7 @@ from frigate.util.time import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey" TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
@ -40,11 +41,6 @@ def lower_priority():
os.nice(PROCESS_PRIORITY_LOW) os.nice(PROCESS_PRIORITY_LOW)
class PlaybackFactorEnum(str, Enum):
realtime = "realtime"
timelapse_25x = "timelapse_25x"
class PlaybackSourceEnum(str, Enum): class PlaybackSourceEnum(str, Enum):
recordings = "recordings" recordings = "recordings"
preview = "preview" preview = "preview"
@ -62,9 +58,10 @@ class RecordingExporter(threading.Thread):
image: Optional[str], image: Optional[str],
start_time: int, start_time: int,
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum, playback_source: PlaybackSourceEnum,
export_case_id: Optional[str] = None, export_case_id: Optional[str] = None,
ffmpeg_input_args: Optional[str] = None,
ffmpeg_output_args: Optional[str] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.config = config self.config = config
@ -74,9 +71,10 @@ class RecordingExporter(threading.Thread):
self.user_provided_image = image self.user_provided_image = image
self.start_time = start_time self.start_time = start_time
self.end_time = end_time self.end_time = end_time
self.playback_factor = playback_factor
self.playback_source = playback_source self.playback_source = playback_source
self.export_case_id = export_case_id 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 # ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True) 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" ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
if self.playback_factor == PlaybackFactorEnum.realtime: if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None:
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.ffmpeg_path,
self.config.cameras[self.camera].record.export.hwaccel_args, self.config.cameras[self.camera].record.export.hwaccel_args,
f"-an {ffmpeg_input}", f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(),
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart", f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
EncodeTypeEnum.timelapse, EncodeTypeEnum.timelapse,
) )
).split(" ") ).split(" ")
else:
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
).split(" ")
# add metadata # 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)}" 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" "-y -protocol_whitelist pipe,file,tcp -f concat -safe 0 -i /dev/stdin"
) )
if self.playback_factor == PlaybackFactorEnum.realtime: if self.ffmpeg_input_args is not None and self.ffmpeg_output_args is not None:
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:
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.ffmpeg_path, self.config.ffmpeg.ffmpeg_path,
self.config.cameras[self.camera].record.export.hwaccel_args, self.config.cameras[self.camera].record.export.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", f"{self.ffmpeg_input_args} {TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}".strip(),
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}", f"{self.ffmpeg_output_args} -movflags +faststart {video_path}".strip(),
EncodeTypeEnum.timelapse, EncodeTypeEnum.timelapse,
) )
).split(" ") ).split(" ")
else:
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}"
).split(" ")
# add metadata # 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)}" title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"

View File

@ -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: if new_config.get("record", {}).get("sync_recordings") is not None:
del new_config["record"]["sync_recordings"] 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(): for name, camera in config.get("cameras", {}).items():
camera_config: dict[str, dict[str, Any]] = camera.copy() camera_config: dict[str, dict[str, Any]] = camera.copy()
if camera_config.get("record", {}).get("sync_recordings") is not None: if camera_config.get("record", {}).get("sync_recordings") is not None:
del camera_config["record"]["sync_recordings"] 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["cameras"][name] = camera_config
new_config["version"] = "0.18-0" new_config["version"] = "0.18-0"