Save exports to database (#11040)

* Save review thumbs in dedicated folder

* Create exports table

* Save exports to DB and save thumbnail for export

* Save full frame always

* Fix rounded corners

* Save exports that are in progress

* No need to remove spaces

* Reorganize apis to use IDs

* Use new apis for frontend

* Get video playback working

* Fix deleting and renaming

* Import existing exports to DB

* Implement downloading

* Formatting
This commit is contained in:
Nicolas Mowen
2024-04-19 16:11:41 -06:00
committed by GitHub
parent 3b0f9988df
commit fe4fb645d3
13 changed files with 584 additions and 284 deletions

View File

@@ -3,18 +3,27 @@
import datetime
import logging
import os
import random
import shutil
import string
import subprocess as sp
import threading
from enum import Enum
from pathlib import Path
from frigate.config import FrigateConfig
from frigate.const import EXPORT_DIR, MAX_PLAYLIST_SECONDS
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
EXPORT_DIR,
MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE,
)
from frigate.ffmpeg_presets import (
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Recordings
from frigate.models import Export, Previews, Recordings
logger = logging.getLogger(__name__)
@@ -51,20 +60,122 @@ class RecordingExporter(threading.Thread):
self.end_time = end_time
self.playback_factor = playback_factor
# ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
def get_datetime_from_timestamp(self, timestamp: int) -> str:
"""Convenience fun to get a simple date time from timestamp."""
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y_%m_%d_%H_%M")
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y/%m/%d %H:%M")
def save_thumbnail(self, id: str) -> str:
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
if datetime.datetime.fromtimestamp(
self.start_time
) < datetime.datetime.now().replace(minute=0, second=0):
# has preview mp4
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
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)
.limit(1)
.get()
)
if not preview:
return ""
diff = self.start_time - preview.start_time
minutes = int(diff / 60)
seconds = int(diff % 60)
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-ss",
f"00:{minutes}:{seconds}",
"-i",
preview.path,
"-c:v",
"libwebp",
thumb_path,
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode != 0:
logger.error(process.stderr)
return ""
else:
# need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
file_start = f"preview_{self.camera}"
start_file = f"{file_start}-{self.start_time}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}-{self.end_time}.{PREVIEW_FRAME_TYPE}"
selected_preview = None
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
continue
if file > end_file:
break
selected_preview = os.path.join(preview_dir, file)
break
if not selected_preview:
return ""
shutil.copyfile(selected_preview, thumb_path)
return thumb_path
def run(self) -> None:
logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
)
file_name = (
export_id = f"{self.camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
export_name = (
self.user_provided_name
or f"{self.camera}_{self.get_datetime_from_timestamp(self.start_time)}__{self.get_datetime_from_timestamp(self.end_time)}"
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
)
file_path = f"{EXPORT_DIR}/in_progress.{file_name}.mp4"
final_file_path = f"{EXPORT_DIR}/{file_name}.mp4"
video_path = f"{EXPORT_DIR}/{export_id}.mp4"
thumb_path = self.save_thumbnail(export_id)
Export.insert(
{
Export.id: 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:
playlist_lines = f"http://127.0.0.1:5000/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
@@ -103,14 +214,14 @@ class RecordingExporter(threading.Thread):
if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = (
f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}"
f"ffmpeg -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.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}",
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
EncodeTypeEnum.timelapse,
)
).split(" ")
@@ -128,9 +239,71 @@ class RecordingExporter(threading.Thread):
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
)
logger.error(p.stderr)
Path(file_path).unlink(missing_ok=True)
Path(video_path).unlink(missing_ok=True)
Export.delete().where(Export.id == export_id).execute()
Path(thumb_path).unlink(missing_ok=True)
return
else:
Export.update({Export.in_progress: False}).where(
Export.id == export_id
).execute()
logger.debug(f"Updating finalized export {file_path}")
os.rename(file_path, final_file_path)
logger.debug(f"Finished exporting {file_path}")
logger.debug(f"Finished exporting {video_path}")
def migrate_exports(camera_names: list[str]):
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
exports = []
for export_file in os.listdir(EXPORT_DIR):
camera = "unknown"
for cam_name in camera_names:
if cam_name in export_file:
camera = cam_name
break
id = f"{camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
video_path = os.path.join(EXPORT_DIR, export_file)
thumb_path = os.path.join(
CLIPS_DIR, f"export/{id}.jpg"
) # use jpg because webp encoder can't get quality low enough
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"warning",
"-i",
video_path,
"-vf",
"scale=-1:180",
"-frames",
"1",
"-q:v",
"8",
thumb_path,
]
process = sp.run(
ffmpeg_cmd,
capture_output=True,
)
if process.returncode != 0:
logger.error(process.stderr)
continue
exports.append(
{
Export.id: id,
Export.camera: camera,
Export.name: export_file.replace(".mp4", ""),
Export.date: os.path.getctime(video_path),
Export.video_path: video_path,
Export.thumb_path: thumb_path,
Export.in_progress: False,
}
)
Export.insert_many(exports).execute()