mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Export preview via api (#14535)
* Break out recording to separate function * Implement preview exporting * Formatting
This commit is contained in:
		
							parent
							
								
									fa81d87dc0
								
							
						
					
					
						commit
						18824830fd
					
				@ -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,12 +60,16 @@ def export_recording(
 | 
			
		||||
 | 
			
		||||
    existing_image = json.get("image_path")
 | 
			
		||||
 | 
			
		||||
    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))
 | 
			
		||||
                | (
 | 
			
		||||
                    (start_time > Recordings.start_time)
 | 
			
		||||
                    & (end_time < Recordings.end_time)
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            .where(Recordings.camera == camera_name)
 | 
			
		||||
            .count()
 | 
			
		||||
@ -73,6 +82,25 @@ def export_recording(
 | 
			
		||||
                ),
 | 
			
		||||
                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(
 | 
			
		||||
@ -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(
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user