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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 584 additions and 284 deletions

View File

@ -15,6 +15,7 @@ from peewee import operator
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.event import EventBp from frigate.api.event import EventBp
from frigate.api.export import ExportBp
from frigate.api.media import MediaBp from frigate.api.media import MediaBp
from frigate.api.preview import PreviewBp from frigate.api.preview import PreviewBp
from frigate.api.review import ReviewBp from frigate.api.review import ReviewBp
@ -39,6 +40,7 @@ logger = logging.getLogger(__name__)
bp = Blueprint("frigate", __name__) bp = Blueprint("frigate", __name__)
bp.register_blueprint(EventBp) bp.register_blueprint(EventBp)
bp.register_blueprint(ExportBp)
bp.register_blueprint(MediaBp) bp.register_blueprint(MediaBp)
bp.register_blueprint(PreviewBp) bp.register_blueprint(PreviewBp)
bp.register_blueprint(ReviewBp) bp.register_blueprint(ReviewBp)

157
frigate/api/export.py Normal file
View File

@ -0,0 +1,157 @@
"""Export apis."""
import logging
from pathlib import Path
from typing import Optional
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from peewee import DoesNotExist
from werkzeug.utils import secure_filename
from frigate.models import Export, Recordings
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
logger = logging.getLogger(__name__)
ExportBp = Blueprint("exports", __name__)
@ExportBp.route("/exports")
def get_exports():
exports = Export.select().order_by(Export.date.desc()).dicts().iterator()
return jsonify([e for e in exports])
@ExportBp.route(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)
@ExportBp.route(
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>",
methods=["POST"],
)
def export_recording(camera_name: str, start_time, end_time):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
{"success": False, "message": f"{camera_name} is not a valid camera."}
),
404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
playback_factor = json.get("playback", "realtime")
name: Optional[str] = json.get("name")
if len(name or "") > 256:
return make_response(
jsonify({"success": False, "message": "File name is too long."}),
401,
)
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))
)
.where(Recordings.camera == camera_name)
.count()
)
if recordings_count <= 0:
return make_response(
jsonify(
{"success": False, "message": "No recordings found for time range"}
),
400,
)
exporter = RecordingExporter(
current_app.frigate_config,
camera_name,
secure_filename(name) if name else None,
int(start_time),
int(end_time),
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
)
exporter.start()
return make_response(
jsonify(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
)
@ExportBp.route("/export/<id>/<new_name>", methods=["PATCH"])
def export_rename(id, new_name: str):
try:
export: Export = Export.get(Export.id == id)
except DoesNotExist:
return make_response(
jsonify(
{
"success": False,
"message": "Export not found.",
}
),
404,
)
export.name = new_name
export.save()
return make_response(
jsonify(
{
"success": True,
"message": "Successfully renamed export.",
}
),
200,
)
@ExportBp.route("/export/<id>", methods=["DELETE"])
def export_delete(id: str):
try:
export: Export = Export.get(Export.id == id)
except DoesNotExist:
return make_response(
jsonify(
{
"success": False,
"message": "Export not found.",
}
),
404,
)
Path(export.video_path).unlink(missing_ok=True)
if export.thumb_path:
Path(export.thumb_path).unlink(missing_ok=True)
export.delete_instance()
return make_response(
jsonify(
{
"success": True,
"message": "Successfully deleted export.",
}
),
200,
)

View File

@ -4,11 +4,9 @@ import base64
import glob import glob
import logging import logging
import os import os
import re
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
@ -22,13 +20,11 @@ from werkzeug.utils import secure_filename
from frigate.const import ( from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
EXPORT_DIR,
MAX_SEGMENT_DURATION, MAX_SEGMENT_DURATION,
PREVIEW_FRAME_TYPE, PREVIEW_FRAME_TYPE,
RECORD_DIR, RECORD_DIR,
) )
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.record.export import PlaybackFactorEnum, RecordingExporter
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -592,151 +588,6 @@ def vod_event(id):
) )
@MediaBp.route(
"/export/<camera_name>/start/<int:start_time>/end/<int:end_time>", methods=["POST"]
)
@MediaBp.route(
"/export/<camera_name>/start/<float:start_time>/end/<float:end_time>",
methods=["POST"],
)
def export_recording(camera_name: str, start_time, end_time):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return make_response(
jsonify(
{"success": False, "message": f"{camera_name} is not a valid camera."}
),
404,
)
json: dict[str, any] = request.get_json(silent=True) or {}
playback_factor = json.get("playback", "realtime")
name: Optional[str] = json.get("name")
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))
)
.where(Recordings.camera == camera_name)
.count()
)
if recordings_count <= 0:
return make_response(
jsonify(
{"success": False, "message": "No recordings found for time range"}
),
400,
)
exporter = RecordingExporter(
current_app.frigate_config,
camera_name,
secure_filename(name.replace(" ", "_")) if name else None,
int(start_time),
int(end_time),
(
PlaybackFactorEnum[playback_factor]
if playback_factor in PlaybackFactorEnum.__members__.values()
else PlaybackFactorEnum.realtime
),
)
exporter.start()
return make_response(
jsonify(
{
"success": True,
"message": "Starting export of recording.",
}
),
200,
)
def export_filename_check_extension(filename: str):
if filename.endswith(".mp4"):
return filename
else:
return filename + ".mp4"
def export_filename_is_valid(filename: str):
if re.search(r"[^:_A-Za-z0-9]", filename) or filename.startswith("in_progress."):
return False
else:
return True
@MediaBp.route("/export/<file_name_current>/<file_name_new>", methods=["PATCH"])
def export_rename(file_name_current, file_name_new: str):
safe_file_name_current = secure_filename(
export_filename_check_extension(file_name_current)
)
file_current = os.path.join(EXPORT_DIR, safe_file_name_current)
if not os.path.exists(file_current):
return make_response(
jsonify({"success": False, "message": f"{file_name_current} not found."}),
404,
)
if not export_filename_is_valid(file_name_new):
return make_response(
jsonify(
{
"success": False,
"message": f"{file_name_new} contains illegal characters.",
}
),
400,
)
safe_file_name_new = secure_filename(export_filename_check_extension(file_name_new))
file_new = os.path.join(EXPORT_DIR, safe_file_name_new)
if os.path.exists(file_new):
return make_response(
jsonify({"success": False, "message": f"{file_name_new} already exists."}),
400,
)
os.rename(file_current, file_new)
return make_response(
jsonify(
{
"success": True,
"message": "Successfully renamed file.",
}
),
200,
)
@MediaBp.route("/export/<file_name>", methods=["DELETE"])
def export_delete(file_name: str):
safe_file_name = secure_filename(export_filename_check_extension(file_name))
file = os.path.join(EXPORT_DIR, safe_file_name)
if not os.path.exists(file):
return make_response(
jsonify({"success": False, "message": f"{file_name} not found."}),
404,
)
os.unlink(file)
return make_response(
jsonify(
{
"success": True,
"message": "Successfully deleted file.",
}
),
200,
)
@MediaBp.route("/<camera_name>/<label>/snapshot.jpg") @MediaBp.route("/<camera_name>/<label>/snapshot.jpg")
def label_snapshot(camera_name, label): def label_snapshot(camera_name, label):
label = unquote(label) label = unquote(label)
@ -1315,9 +1166,13 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4") @MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4") @MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000): def preview_mp4(camera_name: str, start_ts, end_ts):
file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
path = os.path.join(CACHE_DIR, file_name)
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0): if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
# has preview mp4 # has preview mp4
try:
preview: Previews = ( preview: Previews = (
Previews.select( Previews.select(
Previews.camera, Previews.camera,
@ -1335,6 +1190,8 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
.limit(1) .limit(1)
.get() .get()
) )
except DoesNotExist:
preview = None
if not preview: if not preview:
return make_response( return make_response(
@ -1349,6 +1206,7 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
"warning", "warning",
"-y",
"-ss", "-ss",
f"00:{minutes}:{seconds}", f"00:{minutes}:{seconds}",
"-t", "-t",
@ -1359,13 +1217,11 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
"8", "8",
"-vf", "-vf",
"setpts=0.12*PTS", "setpts=0.12*PTS",
"-loop",
"0",
"-c:v", "-c:v",
"copy", "libx264",
"-f", "-movflags",
"mp4", "+faststart",
"-", path,
] ]
process = sp.run( process = sp.run(
@ -1380,7 +1236,6 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
500, 500,
) )
gif_bytes = process.stdout
else: else:
# need to generate from existing images # need to generate from existing images
preview_dir = os.path.join(CACHE_DIR, "preview_frames") preview_dir = os.path.join(CACHE_DIR, "preview_frames")
@ -1424,13 +1279,11 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
"0", "0",
"-i", "-i",
"/dev/stdin", "/dev/stdin",
"-loop",
"0",
"-c:v", "-c:v",
"libx264", "libx264",
"-f", "-movflags",
"gif", "+faststart",
"-", path,
] ]
process = sp.run( process = sp.run(
@ -1446,11 +1299,14 @@ def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
500, 500,
) )
gif_bytes = process.stdout response = make_response()
response.headers["Content-Description"] = "File Transfer"
response = make_response(gif_bytes) response.headers["Cache-Control"] = "no-cache"
response.headers["Content-Type"] = "image/gif" response.headers["Content-Type"] = "video/mp4"
response.headers["Cache-Control"] = f"private, max-age={max_cache_age}" response.headers["Content-Length"] = os.path.getsize(path)
response.headers["X-Accel-Redirect"] = (
f"/cache/{file_name}" # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
)
return response return response

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,
@ -55,6 +56,7 @@ from frigate.plus import PlusApi
from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.ptz.onvif import OnvifController from frigate.ptz.onvif import OnvifController
from frigate.record.cleanup import RecordingCleanup from frigate.record.cleanup import RecordingCleanup
from frigate.record.export import migrate_exports
from frigate.record.record import manage_recordings from frigate.record.record import manage_recordings
from frigate.review.review import manage_review_segments from frigate.review.review import manage_review_segments
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
@ -320,6 +322,7 @@ class FrigateApp:
) )
models = [ models = [
Event, Event,
Export,
Previews, Previews,
Recordings, Recordings,
RecordingsToDelete, RecordingsToDelete,
@ -329,6 +332,17 @@ class FrigateApp:
] ]
self.db.bind(models) self.db.bind(models)
def check_db_data_migrations(self) -> None:
# check if vacuum needs to be run
if not os.path.exists(f"{CONFIG_DIR}/.exports"):
try:
with open(f"{CONFIG_DIR}/.exports", "w") as f:
f.write(str(datetime.datetime.now().timestamp()))
except PermissionError:
logger.error("Unable to write to /config to save export state")
migrate_exports(self.config.cameras.keys())
def init_external_event_processor(self) -> None: def init_external_event_processor(self) -> None:
self.external_event_processor = ExternalEventProcessor(self.config) self.external_event_processor = ExternalEventProcessor(self.config)
@ -629,6 +643,7 @@ class FrigateApp:
self.init_review_segment_manager() self.init_review_segment_manager()
self.init_go2rtc() self.init_go2rtc()
self.bind_database() self.bind_database()
self.check_db_data_migrations()
self.init_inter_process_communicator() self.init_inter_process_communicator()
self.init_dispatcher() self.init_dispatcher()
except Exception as e: except Exception as e:

View File

@ -77,6 +77,16 @@ class Recordings(Model): # type: ignore[misc]
regions = IntegerField(null=True) regions = IntegerField(null=True)
class Export(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20)
name = CharField(index=True, max_length=100)
date = DateTimeField()
video_path = CharField(unique=True)
thumb_path = CharField(unique=True)
in_progress = BooleanField()
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__)
@ -51,20 +60,122 @@ class RecordingExporter(threading.Thread):
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 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}"
) )
file_name = ( export_id = f"{self.camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
export_name = (
self.user_provided_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" video_path = f"{EXPORT_DIR}/{export_id}.mp4"
final_file_path = f"{EXPORT_DIR}/{file_name}.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: 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"
@ -103,14 +214,14 @@ class RecordingExporter(threading.Thread):
if self.playback_factor == PlaybackFactorEnum.realtime: if self.playback_factor == PlaybackFactorEnum.realtime:
ffmpeg_cmd = ( 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(" ") ).split(" ")
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
ffmpeg_cmd = ( ffmpeg_cmd = (
parse_preset_hardware_acceleration_encode( parse_preset_hardware_acceleration_encode(
self.config.ffmpeg.hwaccel_args, self.config.ffmpeg.hwaccel_args,
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", 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, EncodeTypeEnum.timelapse,
) )
).split(" ") ).split(" ")
@ -128,9 +239,71 @@ class RecordingExporter(threading.Thread):
f"Failed to export recording for command {' '.join(ffmpeg_cmd)}" f"Failed to export recording for command {' '.join(ffmpeg_cmd)}"
) )
logger.error(p.stderr) 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 return
else:
Export.update({Export.in_progress: False}).where(
Export.id == export_id
).execute()
logger.debug(f"Updating finalized export {file_path}") logger.debug(f"Finished exporting {video_path}")
os.rename(file_path, final_file_path)
logger.debug(f"Finished exporting {file_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()

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"thumb-{self.camera}-{self.id}.jpg") self.frame_path = os.path.join(
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
)
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:

View File

@ -0,0 +1,37 @@
"""Peewee migrations -- 024_create_export_table.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE TABLE IF NOT EXISTS "export" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "camera" VARCHAR(20) NOT NULL, "name" VARCHAR(100) NOT NULL, "date" DATETIME NOT NULL, "video_path" VARCHAR(255) NOT NULL, "thumb_path" VARCHAR(255) NOT NULL, "in_progress" INTEGER NOT NULL)'
)
migrator.sql('CREATE INDEX IF NOT EXISTS "export_camera" ON "export" ("camera")')
def rollback(migrator, database, fake=False, **kwargs):
pass

View File

@ -11,7 +11,7 @@ import { Redirect } from "./components/navigation/Redirect";
const Live = lazy(() => import("@/pages/Live")); const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events")); const Events = lazy(() => import("@/pages/Events"));
const Export = lazy(() => import("@/pages/Export")); const Exports = lazy(() => import("@/pages/Exports"));
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus")); const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
const System = lazy(() => import("@/pages/System")); const System = lazy(() => import("@/pages/System"));
@ -38,7 +38,7 @@ function App() {
<Route path="/" element={<Live />} /> <Route path="/" element={<Live />} />
<Route path="/events" element={<Redirect to="/review" />} /> <Route path="/events" element={<Redirect to="/review" />} />
<Route path="/review" element={<Events />} /> <Route path="/review" element={<Events />} />
<Route path="/export" element={<Export />} /> <Route path="/export" element={<Exports />} />
<Route path="/plus" element={<SubmitPlus />} /> <Route path="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} /> <Route path="/system" element={<System />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View File

@ -63,7 +63,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
}} }}
> >
<div <div
className="size-full rounded cursor-pointer" className="size-full rounded cursor-pointer overflow-hidden"
onClick={onOpenReview} onClick={onOpenReview}
> >
{previews ? ( {previews ? (

View File

@ -1,38 +1,36 @@
import { baseUrl } from "@/api/baseUrl";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { LuPencil, LuTrash } from "react-icons/lu"; import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useMemo, useRef, useState } from "react"; import { useState } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaPlay } from "react-icons/fa"; import { FaDownload, FaPlay } from "react-icons/fa";
import Chip from "../indicators/Chip"; import Chip from "../indicators/Chip";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { Dialog, DialogContent, DialogFooter, DialogTitle } from "../ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl";
type ExportProps = { type ExportProps = {
className: string; className: string;
file: { exportedRecording: Export;
name: string; onSelect: (selected: Export) => void;
};
onRename: (original: string, update: string) => void; onRename: (original: string, update: string) => void;
onDelete: (file: string) => void; onDelete: (file: string) => void;
}; };
export default function ExportCard({ export default function ExportCard({
className, className,
file, exportedRecording,
onSelect,
onRename, onRename,
onDelete, onDelete,
}: ExportProps) { }: ExportProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [playing, setPlaying] = useState(false); const [loading, setLoading] = useState(
const [loading, setLoading] = useState(true); exportedRecording.thumb_path.length > 0,
const inProgress = useMemo(
() => file.name.startsWith("in_progress"),
[file.name],
); );
// editing name // editing name
@ -46,7 +44,7 @@ export default function ExportCard({
editName != undefined ? ["Enter"] : [], editName != undefined ? ["Enter"] : [],
(_, down, repeat) => { (_, down, repeat) => {
if (down && !repeat && editName && editName.update.length > 0) { if (down && !repeat && editName && editName.update.length > 0) {
onRename(editName.original, editName.update.replaceAll(" ", "_")); onRename(exportedRecording.id, editName.update);
setEditName(undefined); setEditName(undefined);
} }
}, },
@ -84,10 +82,7 @@ export default function ExportCard({
variant="select" variant="select"
disabled={(editName?.update?.length ?? 0) == 0} disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => { onClick={() => {
onRename( onRename(exportedRecording.id, editName.update);
editName.original,
editName.update.replaceAll(" ", "_"),
);
setEditName(undefined); setEditName(undefined);
}} }}
> >
@ -102,75 +97,84 @@ export default function ExportCard({
<div <div
className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`} className={`relative aspect-video bg-black rounded-2xl flex justify-center items-center ${className}`}
onMouseEnter={ onMouseEnter={
isDesktop && !inProgress ? () => setHovered(true) : undefined isDesktop && !exportedRecording.in_progress
? () => setHovered(true)
: undefined
} }
onMouseLeave={ onMouseLeave={
isDesktop && !inProgress ? () => setHovered(false) : undefined isDesktop && !exportedRecording.in_progress
? () => setHovered(false)
: undefined
} }
onClick={ onClick={
isDesktop || inProgress ? undefined : () => setHovered(!hovered) isDesktop || exportedRecording.in_progress
? undefined
: () => setHovered(!hovered)
} }
> >
{hovered && ( {hovered && (
<> <>
{!playing && (
<div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" /> <div className="absolute inset-0 z-10 bg-black bg-opacity-60 rounded-2xl" />
)}
<div className="absolute top-1 right-1 flex items-center gap-2"> <div className="absolute top-1 right-1 flex items-center gap-2">
<a
className="z-20"
download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
>
<Chip className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
<Chip <Chip
className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer" className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer"
onClick={() => setEditName({ original: file.name, update: "" })} onClick={() =>
setEditName({ original: exportedRecording.name, update: "" })
}
> >
<LuPencil className="size-4 text-white" /> <MdEditSquare className="size-4 text-white" />
</Chip> </Chip>
<Chip <Chip
className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer" className="bg-gradient-to-br from-gray-400 to-gray-500 bg-gray-500 rounded-md cursor-pointer"
onClick={() => onDelete(file.name)} onClick={() => onDelete(exportedRecording.id)}
> >
<LuTrash className="size-4 text-destructive fill-destructive" /> <LuTrash className="size-4 text-destructive fill-destructive" />
</Chip> </Chip>
</div> </div>
{!playing && (
<Button <Button
className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-20 h-20 z-20 text-white hover:text-white hover:bg-transparent" className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 w-20 h-20 z-20 text-white hover:text-white hover:bg-transparent cursor-pointer"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setPlaying(true); onSelect(exportedRecording);
videoRef.current?.play();
}} }}
> >
<FaPlay /> <FaPlay />
</Button> </Button>
)}
</> </>
)} )}
{inProgress ? ( {exportedRecording.in_progress ? (
<ActivityIndicator /> <ActivityIndicator />
) : ( ) : (
<video <>
ref={videoRef} {exportedRecording.thumb_path.length > 0 ? (
className="absolute inset-0 aspect-video rounded-2xl" <img
playsInline className="size-full absolute inset-0 object-contain aspect-video rounded-2xl"
preload="auto" src={exportedRecording.thumb_path.replace("/media/frigate", "")}
muted onLoad={() => setLoading(false)}
controls={playing} />
onLoadedData={() => setLoading(false)} ) : (
> <div className="absolute inset-0 bg-secondary rounded-2xl" />
<source src={`${baseUrl}exports/${file.name}`} type="video/mp4" /> )}
</video> </>
)} )}
{loading && ( {loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-2xl" /> <Skeleton className="absolute inset-0 aspect-video rounded-2xl" />
)} )}
{!playing && (
<div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-2xl"> <div className="absolute bottom-0 inset-x-0 rounded-b-l z-10 h-[20%] bg-gradient-to-t from-black/60 to-transparent pointer-events-none rounded-2xl">
<div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize"> <div className="flex h-full justify-between items-end mx-3 pb-1 text-white text-sm capitalize">
{file.name {exportedRecording.name.replaceAll("_", " ")}
.substring(0, file.name.length - 4)
.replaceAll("_", " ")}
</div> </div>
</div> </div>
)}
</div> </div>
</> </>
); );

View File

@ -10,20 +10,15 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Export } from "@/types/export";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
type ExportItem = { function Exports() {
name: string; const { data: exports, mutate } = useSWR<Export[]>("exports");
};
function Export() {
const { data: allExports, mutate } = useSWR<ExportItem[]>(
"exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
);
useEffect(() => { useEffect(() => {
document.title = "Export - Frigate"; document.title = "Export - Frigate";
@ -33,17 +28,18 @@ function Export() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const exports = useMemo(() => { const filteredExports = useMemo(() => {
if (!search || !allExports) { if (!search || !exports) {
return allExports; return exports;
} }
return allExports.filter((exp) => return exports.filter((exp) =>
exp.name exp.name
.toLowerCase() .toLowerCase()
.includes(search.toLowerCase().replaceAll(" ", "_")), .replaceAll("_", " ")
.includes(search.toLowerCase()),
); );
}, [allExports, search]); }, [exports, search]);
// Deleting // Deleting
@ -65,8 +61,8 @@ function Export() {
// Renaming // Renaming
const onHandleRename = useCallback( const onHandleRename = useCallback(
(original: string, update: string) => { (id: string, update: string) => {
axios.patch(`export/${original}/${update}`).then((response) => { axios.patch(`export/${id}/${update}`).then((response) => {
if (response.status == 200) { if (response.status == 200) {
setDeleteClip(undefined); setDeleteClip(undefined);
mutate(); mutate();
@ -76,6 +72,10 @@ function Export() {
[mutate], [mutate],
); );
// Viewing
const [selected, setSelected] = useState<Export>();
return ( return (
<div className="size-full p-2 overflow-hidden flex flex-col gap-2"> <div className="size-full p-2 overflow-hidden flex flex-col gap-2">
<AlertDialog <AlertDialog
@ -91,13 +91,43 @@ function Export() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={() => onHandleDelete()}> <Button
className="text-white"
variant="destructive"
onClick={() => onHandleDelete()}
>
Delete Delete
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<Dialog
open={selected != undefined}
onOpenChange={(open) => {
if (!open) {
setSelected(undefined);
}
}}
>
<DialogContent className="max-w-7xl">
<DialogTitle>{selected?.name}</DialogTitle>
<video
className="size-full rounded-2xl"
playsInline
preload="auto"
autoPlay
controls
muted
>
<source
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
type="video/mp4"
/>
</video>
</DialogContent>
</Dialog>
<div className="w-full p-2 flex items-center justify-center"> <div className="w-full p-2 flex items-center justify-center">
<Input <Input
className="w-full md:w-1/3 bg-muted" className="w-full md:w-1/3 bg-muted"
@ -108,17 +138,18 @@ function Export() {
</div> </div>
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
{allExports && exports && ( {exports && filteredExports && (
<div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto"> <div className="size-full grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 overflow-y-auto">
{Object.values(allExports).map((item) => ( {Object.values(exports).map((item) => (
<ExportCard <ExportCard
key={item.name} key={item.name}
className={ className={
search == "" || exports.includes(item) ? "" : "hidden" search == "" || filteredExports.includes(item) ? "" : "hidden"
} }
file={item} exportedRecording={item}
onSelect={setSelected}
onRename={onHandleRename} onRename={onHandleRename}
onDelete={(file) => setDeleteClip(file)} onDelete={(id) => setDeleteClip(id)}
/> />
))} ))}
</div> </div>
@ -128,4 +159,4 @@ function Export() {
); );
} }
export default Export; export default Exports;

9
web/src/types/export.ts Normal file
View File

@ -0,0 +1,9 @@
export type Export = {
id: string;
camera: string;
name: string;
date: number;
video_path: string;
thumb_path: string;
in_progress: boolean;
};