mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-11 05:35:25 +03:00
Save exports to DB and save thumbnail for export
This commit is contained in:
parent
cbebff4137
commit
27cd5a07e2
@ -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),
|
||||||
(
|
(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user