Save exports to DB and save thumbnail for export

This commit is contained in:
Nicolas Mowen 2024-04-19 07:41:39 -06:00
parent cbebff4137
commit 27cd5a07e2
5 changed files with 136 additions and 3 deletions

View File

@ -611,6 +611,13 @@ def export_recording(camera_name: str, start_time, end_time):
json: dict[str, any] = request.get_json(silent=True) or {} json: dict[str, any] = request.get_json(silent=True) or {}
playback_factor = json.get("playback", "realtime") playback_factor = json.get("playback", "realtime")
name: Optional[str] = json.get("name") name: Optional[str] = json.get("name")
existing_thumb: Optional[str] = json.get("thumb")
if len(name) > 256 or len(existing_thumb) > 256:
return make_response(
jsonify({"success": False, "message": "File name is too long."}),
401,
)
recordings_count = ( recordings_count = (
Recordings.select() Recordings.select()
@ -635,6 +642,7 @@ def export_recording(camera_name: str, start_time, end_time):
current_app.frigate_config, current_app.frigate_config,
camera_name, camera_name,
secure_filename(name.replace(" ", "_")) if name else None, secure_filename(name.replace(" ", "_")) if name else None,
secure_filename(existing_thumb),
int(start_time), int(start_time),
int(end_time), int(end_time),
( (

View File

@ -41,6 +41,7 @@ from frigate.events.maintainer import EventProcessor
from frigate.log import log_process, root_configurer from frigate.log import log_process, root_configurer
from frigate.models import ( from frigate.models import (
Event, Event,
Export,
Previews, Previews,
Recordings, Recordings,
RecordingsToDelete, RecordingsToDelete,
@ -320,6 +321,7 @@ class FrigateApp:
) )
models = [ models = [
Event, Event,
Export,
Previews, Previews,
Recordings, Recordings,
RecordingsToDelete, RecordingsToDelete,

View File

@ -84,6 +84,7 @@ class Export(Model): # type: ignore[misc]
video_path = CharField(unique=True) video_path = CharField(unique=True)
thumb_path = CharField(unique=True) thumb_path = CharField(unique=True)
class ReviewSegment(Model): # type: ignore[misc] class ReviewSegment(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30) id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)

View File

@ -3,18 +3,27 @@
import datetime import datetime
import logging import logging
import os import os
import random
import shutil
import string
import subprocess as sp import subprocess as sp
import threading import threading
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from frigate.config import FrigateConfig 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 ( from frigate.ffmpeg_presets import (
EncodeTypeEnum, EncodeTypeEnum,
parse_preset_hardware_acceleration_encode, parse_preset_hardware_acceleration_encode,
) )
from frigate.models import Recordings from frigate.models import Export, Previews, Recordings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,6 +48,7 @@ class RecordingExporter(threading.Thread):
config: FrigateConfig, config: FrigateConfig,
camera: str, camera: str,
name: str, name: str,
existing_thumb: str,
start_time: int, start_time: int,
end_time: int, end_time: int,
playback_factor: PlaybackFactorEnum, playback_factor: PlaybackFactorEnum,
@ -47,14 +57,106 @@ class RecordingExporter(threading.Thread):
self.config = config self.config = config
self.camera = camera self.camera = camera
self.user_provided_name = name self.user_provided_name = name
self.existing_thumb = existing_thumb
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
# ensure export thumb dir
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
def get_datetime_from_timestamp(self, timestamp: int) -> str: def get_datetime_from_timestamp(self, timestamp: int) -> str:
"""Convenience fun to get a simple date time from timestamp.""" """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 self.existing_thumb and os.path.isfile(self.existing_thumb):
shutil.copyfile(self.existing_thumb, thumb_path)
else:
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: def run(self) -> None:
logger.debug( logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}" f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
@ -133,4 +235,18 @@ class RecordingExporter(threading.Thread):
logger.debug(f"Updating finalized export {file_path}") logger.debug(f"Updating finalized export {file_path}")
os.rename(file_path, final_file_path) os.rename(file_path, final_file_path)
export_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
thumb_path = self.save_thumbnail(export_id)
Export.insert(
{
Export.id: export_id,
Export.camera: self.camera,
Export.date: self.start_time,
Export.video_path: final_file_path,
Export.thumb_path: thumb_path,
}
)
logger.debug(f"Finished exporting {file_path}") logger.debug(f"Finished exporting {file_path}")

View File

@ -9,6 +9,7 @@ import sys
import threading import threading
from enum import Enum from enum import Enum
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Optional from typing import Optional
import cv2 import cv2
@ -64,7 +65,9 @@ class PendingReviewSegment:
# thumbnail # thumbnail
self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
self.frame_active_count = 0 self.frame_active_count = 0
self.frame_path = os.path.join(CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.jpg") self.frame_path = os.path.join(
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.jpg"
)
def update_frame( def update_frame(
self, camera_config: CameraConfig, frame, objects: list[TrackedObject] self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
@ -138,6 +141,9 @@ class ReviewSegmentMaintainer(threading.Thread):
# manual events # manual events
self.indefinite_events: dict[str, dict[str, any]] = {} self.indefinite_events: dict[str, dict[str, any]] = {}
# ensure dirs
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
self.stop_event = stop_event self.stop_event = stop_event
def update_segment(self, segment: PendingReviewSegment) -> None: def update_segment(self, segment: PendingReviewSegment) -> None: