mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
refactor time lapse creation to be a separate API call with ability to pass arbitrary ffmpeg args
This commit is contained in:
parent
d9f8e603c9
commit
c2d09f4029
@ -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.",
|
||||||
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)}"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user