diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index f201ab713..4daf991c8 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -27,6 +27,7 @@ from frigate.api import ( preview, record, review, + transcode, ) from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default from frigate.comms.dispatcher import Dispatcher @@ -142,6 +143,7 @@ def create_fastapi_app( app.include_router(media.router) app.include_router(motion_search.router) app.include_router(record.router) + app.include_router(transcode.router) app.include_router(debug_replay.router) # App Properties app.frigate_config = frigate_config diff --git a/frigate/api/transcode.py b/frigate/api/transcode.py new file mode 100644 index 000000000..71c246f4d --- /dev/null +++ b/frigate/api/transcode.py @@ -0,0 +1,279 @@ +"""On-demand H.265 to H.264 transcoded HLS endpoint.""" + +import asyncio +import logging +import os +import subprocess as sp +import time +from hashlib import md5 +from pathlib import Path + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse + +from frigate.api.auth import require_camera_access, require_role +from frigate.api.defs.tags import Tags +from frigate.config import FrigateConfig +from frigate.const import PROCESS_PRIORITY_LOW + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=[Tags.media]) + +TRANSCODE_DIR = "/tmp/stream/transcode" +MAX_CONCURRENT_TRANSCODES = 3 +TRANSCODE_TTL_SECONDS = 600 +SEGMENT_WAIT_TIMEOUT = 30 + +# Active transcode sessions: session_id -> subprocess.Popen +_active_sessions: dict[str, sp.Popen] = {} + + +def _lower_priority(): + os.nice(PROCESS_PRIORITY_LOW) + + +def _session_id(camera: str, start_ts: float, end_ts: float) -> str: + """Deterministic session ID for deduplication.""" + raw = f"{camera}_{start_ts}_{end_ts}" + return md5(raw.encode()).hexdigest()[:12] + + +def _session_dir(session_id: str) -> str: + return os.path.join(TRANSCODE_DIR, session_id) + + +def _session_playlist(session_id: str) -> str: + return os.path.join(_session_dir(session_id), "master.m3u8") + + +def _is_session_alive(session_id: str) -> bool: + """Check if a transcode session FFmpeg process is still running.""" + proc = _active_sessions.get(session_id) + if proc is None: + return False + return proc.poll() is None + + +def _get_internal_port(config: FrigateConfig) -> int: + """Get the internal API port, handling string ip:port format.""" + internal_port = config.networking.listen.internal + if isinstance(internal_port, str): + internal_port = int(internal_port.split(":")[-1]) + return int(internal_port) + + +def _build_ffmpeg_cmd( + config: FrigateConfig, + camera_name: str, + start_ts: float, + end_ts: float, + session_id: str, +) -> list[str]: + """Build FFmpeg command that consumes existing HLS and outputs transcoded HLS.""" + internal_port = _get_internal_port(config) + input_url = ( + f"http://127.0.0.1:{internal_port}" + f"/vod/{camera_name}/start/{start_ts}/end/{end_ts}/index.m3u8" + ) + + output_dir = _session_dir(session_id) + os.makedirs(output_dir, exist_ok=True) + + output_playlist = os.path.join(output_dir, "master.m3u8") + segment_pattern = os.path.join(output_dir, "segment-%d.ts") + + return [ + config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-loglevel", + "warning", + "-y", + "-protocol_whitelist", + "pipe,file,http,tcp", + "-i", + input_url, + # Encode to H.264 (software — universally available) + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "23", + "-profile:v", + "high", + "-level:v", + "4.1", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "128k", + # Output as HLS with MPEG-TS segments + "-f", + "hls", + "-hls_time", + "6", + "-hls_list_size", + "0", + "-hls_segment_type", + "mpegts", + "-hls_flags", + "independent_segments+append_list", + "-hls_segment_filename", + segment_pattern, + output_playlist, + ] + + +async def _wait_for_playlist(session_id: str) -> bool: + """Wait until FFmpeg has written the playlist and at least one segment.""" + playlist = _session_playlist(session_id) + deadline = time.monotonic() + SEGMENT_WAIT_TIMEOUT + + while time.monotonic() < deadline: + if os.path.exists(playlist) and os.path.getsize(playlist) > 0: + session_dir = _session_dir(session_id) + segments = list(Path(session_dir).glob("segment-*.ts")) + if segments: + return True + + if not _is_session_alive(session_id): + proc = _active_sessions.get(session_id) + if proc: + stderr = proc.stderr.read() if proc.stderr else b"" + logger.error( + "Transcode FFmpeg exited with code %d: %s", + proc.returncode, + stderr.decode(errors="replace"), + ) + return False + + await asyncio.sleep(0.5) + + return False + + +def cleanup_session(session_id: str) -> None: + """Stop FFmpeg and remove temp files for a session.""" + proc = _active_sessions.pop(session_id, None) + if proc and proc.poll() is None: + proc.terminate() + try: + proc.communicate(timeout=10) + except sp.TimeoutExpired: + proc.kill() + proc.communicate() + + session_dir = _session_dir(session_id) + if os.path.exists(session_dir): + for f in Path(session_dir).iterdir(): + f.unlink(missing_ok=True) + try: + Path(session_dir).rmdir() + except OSError: + pass + + +def cleanup_stale_transcode_sessions() -> None: + """Remove transcode sessions older than TTL. Called from cleanup loop.""" + if not os.path.exists(TRANSCODE_DIR): + return + + now = time.time() + for session_dir in Path(TRANSCODE_DIR).iterdir(): + if not session_dir.is_dir(): + continue + + age = now - session_dir.stat().st_mtime + if age > TRANSCODE_TTL_SECONDS: + session_id = session_dir.name + logger.debug("Cleaning up stale transcode session %s", session_id) + cleanup_session(session_id) + + +@router.get( + "/transcode/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], + description="Start a transcoded HLS session that converts H.265 recordings to H.264 for browser compatibility.", +) +async def transcoded_vod( + request: Request, + camera_name: str, + start_ts: float, + end_ts: float, +): + """Start or reuse a transcoded HLS session for the given time range.""" + config: FrigateConfig = request.app.frigate_config + session_id = _session_id(camera_name, start_ts, end_ts) + + # Reuse existing session if still alive + if _is_session_alive(session_id): + playlist = _session_playlist(session_id) + if os.path.exists(playlist): + return JSONResponse( + content={ + "success": True, + "playlist": f"/stream/transcode/{session_id}/master.m3u8", + } + ) + + # Check concurrent transcode limit + active_count = sum(1 for p in _active_sessions.values() if p.poll() is None) + if active_count >= MAX_CONCURRENT_TRANSCODES: + return JSONResponse( + content={ + "success": False, + "message": "Too many concurrent transcode sessions", + }, + status_code=429, + ) + + ffmpeg_cmd = _build_ffmpeg_cmd(config, camera_name, start_ts, end_ts, session_id) + + logger.info( + "Starting transcode session %s for %s (%s -> %s)", + session_id, + camera_name, + start_ts, + end_ts, + ) + + proc = sp.Popen( + ffmpeg_cmd, + stdout=sp.DEVNULL, + stderr=sp.PIPE, + stdin=sp.DEVNULL, + start_new_session=True, + preexec_fn=_lower_priority, + ) + _active_sessions[session_id] = proc + + ready = await _wait_for_playlist(session_id) + if not ready: + cleanup_session(session_id) + return JSONResponse( + content={ + "success": False, + "message": "Transcode timed out waiting for first segment", + }, + status_code=504, + ) + + return JSONResponse( + content={ + "success": True, + "playlist": f"/stream/transcode/{session_id}/master.m3u8", + } + ) + + +@router.delete( + "/transcode/{session_id}", + dependencies=[Depends(require_role(["admin"]))], +) +async def stop_transcode(session_id: str): + """Stop and clean up a transcode session.""" + cleanup_session(session_id) + return JSONResponse(content={"success": True}) diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 15a0ba7e8..303506be0 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -10,6 +10,7 @@ from pathlib import Path from playhouse.sqlite_ext import SqliteExtDatabase +from frigate.api.transcode import cleanup_stale_transcode_sessions from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus @@ -375,6 +376,7 @@ class RecordingCleanup(threading.Thread): break self.clean_tmp_previews() + cleanup_stale_transcode_sessions() if counter == 0: self.clean_tmp_clips() diff --git a/web/src/components/overlay/detail/TrackingDetails.tsx b/web/src/components/overlay/detail/TrackingDetails.tsx index 351370ad8..10adf3bac 100644 --- a/web/src/components/overlay/detail/TrackingDetails.tsx +++ b/web/src/components/overlay/detail/TrackingDetails.tsx @@ -41,6 +41,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator import ObjectTrackOverlay from "../ObjectTrackOverlay"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { VideoResolutionType } from "@/types/live"; +import { useTranscodedPlayback } from "@/hooks/use-transcoded-playback"; type TrackingDetailsProps = { className?: string; @@ -513,7 +514,14 @@ export function TrackingDetails({ setBlueLineHeightPx(bluePx); }, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]); - const videoSource = useMemo(() => { + const { resolvePlaylistUrl } = useTranscodedPlayback(baseUrl); + + const [videoSource, setVideoSource] = useState({ + playlist: "", + startPosition: 0, + }); + + useEffect(() => { // event.start_time and event.end_time are in DETECT stream time // Convert to record stream time, then create video clip with padding. // Use sourceOffsetRef (stable per event) so the HLS player doesn't @@ -524,12 +532,13 @@ export function TrackingDetails({ (event.end_time ?? Date.now() / 1000) + sourceOffset / 1000; const startTime = eventStartRec - REVIEW_PADDING; const endTime = eventEndRec + REVIEW_PADDING; - const playlist = `${baseUrl}vod/clip/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`; + const vodUrl = `${baseUrl}vod/clip/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`; - return { - playlist, - startPosition: 0, - }; + resolvePlaylistUrl(vodUrl, event.camera, startTime, endTime).then( + (playlist: string) => { + setVideoSource({ playlist, startPosition: 0 }); + }, + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [event]); diff --git a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx index c8d95090d..ae5e6514b 100644 --- a/web/src/components/player/dynamic/DynamicVideoPlayer.tsx +++ b/web/src/components/player/dynamic/DynamicVideoPlayer.tsx @@ -26,6 +26,7 @@ import { calculateSeekPosition, } from "@/utils/videoUtil"; import { isFirefox } from "react-device-detect"; +import { useTranscodedPlayback } from "@/hooks/use-transcoded-playback"; /** * Dynamically switches between video playback and scrubbing preview player. @@ -71,6 +72,7 @@ export default function DynamicVideoPlayer({ const { t } = useTranslation(["components/player"]); const apiHost = useApiHost(); const { data: config } = useSWR("config"); + const { resolvePlaylistUrl } = useTranscodedPlayback(apiHost); // for detail stream context in History const { @@ -219,9 +221,15 @@ export default function DynamicVideoPlayer({ ); } - setSource({ - playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`, - startPosition, + const vodUrl = `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`; + + resolvePlaylistUrl( + vodUrl, + camera, + recordingParams.after, + recordingParams.before, + ).then((playlist: string) => { + setSource({ playlist, startPosition }); }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/web/src/hooks/use-transcoded-playback.ts b/web/src/hooks/use-transcoded-playback.ts new file mode 100644 index 000000000..82aafafed --- /dev/null +++ b/web/src/hooks/use-transcoded-playback.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef } from "react"; +import { isHevcSupported } from "@/utils/codecUtil"; +import axios from "axios"; + +interface TranscodeResponse { + success: boolean; + playlist?: string; + message?: string; +} + +/** + * Resolves an HLS playlist URL, requesting server-side H.265->H.264 + * transcoding when the browser does not support HEVC natively. + * + * When HEVC is supported, returns the original VOD URL unchanged. + */ +export function useTranscodedPlayback(apiHost: string) { + const activeSessionRef = useRef(null); + + useEffect(() => { + return () => { + if (activeSessionRef.current) { + axios + .delete(`${apiHost}api/transcode/${activeSessionRef.current}`) + .catch(() => {}); + } + }; + }, [apiHost]); + + const resolvePlaylistUrl = useCallback( + async (vodUrl: string, camera: string, startTs: number, endTs: number) => { + if (isHevcSupported()) { + return vodUrl; + } + + // Clean up previous session + if (activeSessionRef.current) { + axios + .delete(`${apiHost}api/transcode/${activeSessionRef.current}`) + .catch(() => {}); + activeSessionRef.current = null; + } + + try { + const resp = await axios.get( + `${apiHost}api/transcode/${camera}/start/${startTs}/end/${endTs}`, + ); + + if (resp.data.success && resp.data.playlist) { + const sessionId = resp.data.playlist.split("/").at(-2) ?? null; + activeSessionRef.current = sessionId; + return `${apiHost}${resp.data.playlist.replace(/^\//, "")}`; + } + } catch (err) { + console.warn("Transcode request failed, falling back to native HLS", err); + } + + // Fallback to original URL + return vodUrl; + }, + [apiHost], + ); + + return { resolvePlaylistUrl }; +} diff --git a/web/src/utils/codecUtil.ts b/web/src/utils/codecUtil.ts new file mode 100644 index 000000000..729da8592 --- /dev/null +++ b/web/src/utils/codecUtil.ts @@ -0,0 +1,39 @@ +/** + * Browser HEVC/H.265 codec support detection. + */ + +let hevcResult: boolean | null = null; + +/** + * Detect whether the browser supports HEVC/H.265 playback + * via Media Source Extensions or native video element. + * Result is cached after the first call. + */ +export function isHevcSupported(): boolean { + if (hevcResult !== null) return hevcResult; + + const codecs = [ + 'video/mp4; codecs="hvc1.1.6.L153.B0"', + 'video/mp4; codecs="hev1.1.6.L153.B0"', + ]; + + if (typeof MediaSource !== "undefined") { + for (const codec of codecs) { + if (MediaSource.isTypeSupported(codec)) { + hevcResult = true; + return true; + } + } + } + + const video = document.createElement("video"); + for (const codec of codecs) { + if (video.canPlayType(codec) === "probably") { + hevcResult = true; + return true; + } + } + + hevcResult = false; + return false; +}