diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 6dddfc615..f83b03dd5 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -69,6 +69,7 @@ http { vod_mode mapped; vod_max_mapping_response_size 1m; vod_upstream_location /api; + vod_remote_upstream_location '/transcode'; vod_align_segments_to_key_frames on; vod_manifest_segment_durations_mode accurate; vod_ignore_edit_list on; @@ -102,6 +103,12 @@ http { include auth_location.conf; include base_path.conf; + location ~ /transcode/(.*) { + include auth_request.conf; + internal; + proxy_pass 'http://frigate_api/vod/transcode?file=$1'; + } + location /vod/ { include auth_request.conf; aio threads; diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index afb7c9059..82006bc87 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -34,6 +34,7 @@ from frigate.embeddings import EmbeddingsContext from frigate.ptz.onvif import OnvifController from frigate.stats.emitter import StatsEmitter from frigate.storage import StorageMaintainer +from frigate.transcode.temp_file_cache import TempFileCache logger = logging.getLogger(__name__) @@ -55,6 +56,7 @@ class RemoteUserPlugin(Plugin): def create_fastapi_app( frigate_config: FrigateConfig, database: SqliteQueueDatabase, + temp_file_cache: TempFileCache, embeddings: Optional[EmbeddingsContext], detected_frames_processor, storage_maintainer: StorageMaintainer, @@ -134,6 +136,7 @@ def create_fastapi_app( app.stats_emitter = stats_emitter app.event_metadata_updater = event_metadata_updater app.config_publisher = config_publisher + app.temp_file_cache = temp_file_cache if frigate_config.auth.enabled: secret = get_jwt_secret() diff --git a/frigate/api/media.py b/frigate/api/media.py index 8d310fec8..f728c5de5 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone from functools import reduce from pathlib import Path as FilePath from typing import Any, List -from urllib.parse import unquote +from urllib.parse import quote, unquote import cv2 import numpy as np @@ -815,6 +815,46 @@ async def recording_clip( ) +@router.get("/vod/transcode") +def clip(request: Request, file: str): + config: FrigateConfig = request.app.frigate_config + + def transcode(input: str, output: str): + ffmpeg_cmd = [ + config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-hwaccel", + "qsv", + "-hwaccel_output_format", + "qsv", + "-i", + input, + "-vf", + "scale_qsv=854:480", + "-c:v", + "h264_qsv", + "-c:a", + "copy", + "-f", + "mp4", + output, + ] + with sp.Popen( + ffmpeg_cmd, + stdout=sp.PIPE, + stderr=sp.PIPE, + text=False, + bufsize=0, + ) as ffmpeg: + ret = ffmpeg.wait() + if ret != 0: + raise Exception("Failed to transcode!") + + cache = request.app.temp_file_cache + transcoded_path = cache.get(file, lambda output: transcode(file, output)) + return FileResponse(transcoded_path, media_type="video/mp4") + + @router.get( "/vod/{camera_name}/start/{start_ts}/end/{end_ts}", dependencies=[Depends(require_camera_access)], @@ -844,7 +884,11 @@ async def vod_ts(camera_name: str, start_ts: float, end_ts: float): recording: Recordings for recording in recordings: - clip = {"type": "source", "path": recording.path} + clip = { + "type": "source", + "sourceType": "http", + "path": f"/{quote(recording.path, safe='')}", + } duration = int(recording.duration * 1000) # adjust start offset if start_ts is after recording.start_time diff --git a/frigate/app.py b/frigate/app.py index 30259ad3d..31ea71fdc 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -73,6 +73,7 @@ from frigate.stats.util import stats_init from frigate.storage import StorageMaintainer from frigate.timeline import TimelineProcessor from frigate.track.object_processing import TrackedObjectProcessor +from frigate.transcode.temp_file_cache import TempFileCache from frigate.util.builtin import empty_and_close_queue from frigate.util.image import UntrackedSharedMemory from frigate.util.services import set_file_limit @@ -225,6 +226,9 @@ class FrigateApp: migrate_db.close() + def init_transcode_cache(self) -> None: + self.transcode_cache = TempFileCache() + def init_go2rtc(self) -> None: for proc in psutil.process_iter(["pid", "name"]): if proc.info["name"] == "go2rtc": @@ -530,6 +534,7 @@ class FrigateApp: self.init_camera_metrics() self.init_queues() self.init_database() + self.init_transcode_cache() self.init_onvif() self.init_recording_manager() self.init_review_segment_manager() @@ -561,6 +566,7 @@ class FrigateApp: create_fastapi_app( self.config, self.db, + self.transcode_cache, self.embeddings, self.detected_frames_processor, self.storage_maintainer, @@ -634,6 +640,7 @@ class FrigateApp: self.stats_emitter.join() self.frigate_watchdog.join() self.db.stop() + self.transcode_cache.stop() # Save embeddings stats to disk if self.embeddings: diff --git a/frigate/transcode/__init__.py b/frigate/transcode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/frigate/transcode/temp_file_cache.py b/frigate/transcode/temp_file_cache.py new file mode 100644 index 000000000..f06da4ba5 --- /dev/null +++ b/frigate/transcode/temp_file_cache.py @@ -0,0 +1,81 @@ +import os +import tempfile +import threading +import time + + +class TempFileCache: + def __init__(self, ttl_seconds=300, cleanup_interval=1): + self.ttl = ttl_seconds + self.cleanup_interval = cleanup_interval + + self.cache = {} # key -> (path, timestamp) + self.pending = set() # keys being generated + self.lock = threading.Condition() + self._stop = False + + # Start background cleanup thread + self.thread = threading.Thread(target=self._cleanup_loop, daemon=True) + self.thread.start() + + def _remove_file(self, path): + if path and os.path.exists(path): + try: + os.remove(path) + except Exception: + pass + + def _cleanup_expired(self): + now = time.time() + expired_keys = [k for k, (_, ts) in self.cache.items() if now - ts > self.ttl] + + for key in expired_keys: + path, _ = self.cache.pop(key, (None, None)) + self._remove_file(path) + + def _cleanup_loop(self): + while not self._stop: + with self.lock: + self._cleanup_expired() + time.sleep(self.cleanup_interval) + + def stop(self): + """Stop the cleanup thread.""" + self._stop = True + self.thread.join(timeout=2) + + def get(self, key, generator_fn): + with self.lock: + # Return cached file if fresh + if key in self.cache: + path, ts = self.cache[key] + # refresh timestamp + self.cache[key] = (path, time.time()) + return path + + # If another thread is generating this file, wait + while key in self.pending: + self.lock.wait() + + # Mark this key as pending generation + self.pending.add(key) + + # Outside lock: generate the file + path = tempfile.mktemp() + + try: + generator_fn(path) + except Exception: + self._remove_file(path) + with self.lock: + self.pending.remove(key) + self.lock.notify_all() + raise + + # Store file and notify waiters + with self.lock: + self.cache[key] = (path, time.time()) + self.pending.remove(key) + self.lock.notify_all() + + return path