Export preview via api (#14535)

* Break out recording to separate function

* Implement preview exporting

* Formatting
This commit is contained in:
Nicolas Mowen 2024-10-23 07:36:52 -06:00 committed by GitHub
parent fa81d87dc0
commit 18824830fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 152 additions and 43 deletions

View File

@ -13,8 +13,12 @@ from peewee import DoesNotExist
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import EXPORT_DIR from frigate.const import EXPORT_DIR
from frigate.models import Export, Recordings from frigate.models import Export, Previews, Recordings
from frigate.record.export import PlaybackFactorEnum, RecordingExporter from frigate.record.export import (
PlaybackFactorEnum,
PlaybackSourceEnum,
RecordingExporter,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,6 +49,7 @@ def export_recording(
json: dict[str, any] = body or {} json: dict[str, any] = body or {}
playback_factor = json.get("playback", "realtime") playback_factor = json.get("playback", "realtime")
playback_source = json.get("source", "recordings")
friendly_name: Optional[str] = json.get("name") friendly_name: Optional[str] = json.get("name")
if len(friendly_name or "") > 256: if len(friendly_name or "") > 256:
@ -55,25 +60,48 @@ def export_recording(
existing_image = json.get("image_path") existing_image = json.get("image_path")
recordings_count = ( if playback_source == "recordings":
Recordings.select() recordings_count = (
.where( Recordings.select()
Recordings.start_time.between(start_time, end_time) .where(
| Recordings.end_time.between(start_time, end_time) Recordings.start_time.between(start_time, end_time)
| ((start_time > Recordings.start_time) & (end_time < Recordings.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: if recordings_count <= 0:
return JSONResponse( return JSONResponse(
content=( content=(
{"success": False, "message": "No recordings found for time range"} {"success": False, "message": "No recordings found for time range"}
), ),
status_code=400, 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))}" export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
exporter = RecordingExporter( exporter = RecordingExporter(
request.app.frigate_config, request.app.frigate_config,
@ -88,6 +116,11 @@ def export_recording(
if playback_factor in PlaybackFactorEnum.__members__.values() if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime else PlaybackFactorEnum.realtime
), ),
(
PlaybackSourceEnum[playback_source]
if playback_source in PlaybackSourceEnum.__members__.values()
else PlaybackSourceEnum.recordings
),
) )
exporter.start() exporter.start()
return JSONResponse( return JSONResponse(

View File

@ -43,6 +43,11 @@ class PlaybackFactorEnum(str, Enum):
timelapse_25x = "timelapse_25x" timelapse_25x = "timelapse_25x"
class PlaybackSourceEnum(str, Enum):
recordings = "recordings"
preview = "preview"
class RecordingExporter(threading.Thread): class RecordingExporter(threading.Thread):
"""Exports a specific set of recordings for a camera to storage as a single file.""" """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, start_time: int,
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum, playback_factor: PlaybackFactorEnum,
playback_source: PlaybackSourceEnum,
) -> None: ) -> None:
super().__init__() super().__init__()
self.config = config self.config = config
@ -66,6 +72,7 @@ class RecordingExporter(threading.Thread):
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_factor = playback_factor
self.playback_source = playback_source
# 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)
@ -170,30 +177,7 @@ class RecordingExporter(threading.Thread):
return thumb_path return thumb_path
def run(self) -> None: def get_record_export_command(self, video_path: str) -> list[str]:
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.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS: 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" playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
ffmpeg_input = ( ffmpeg_input = (
@ -204,7 +188,10 @@ class RecordingExporter(threading.Thread):
# get full set of recordings # get full set of recordings
export_recordings = ( export_recordings = (
Recordings.select() Recordings.select(
Recordings.start_time,
Recordings.end_time,
)
.where( .where(
Recordings.start_time.between(self.start_time, self.end_time) Recordings.start_time.between(self.start_time, self.end_time)
| Recordings.end_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" 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: if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = ( ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart {video_path}" 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(" ") ).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( p = sp.run(
ffmpeg_cmd, ffmpeg_cmd,
input="\n".join(playlist_lines), input="\n".join(playlist_lines),
@ -254,7 +330,7 @@ class RecordingExporter(threading.Thread):
if p.returncode != 0: if p.returncode != 0:
logger.error( 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) logger.error(p.stderr)
Path(video_path).unlink(missing_ok=True) Path(video_path).unlink(missing_ok=True)