mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57:40 +03:00
Add on-demand H.265 to H.264 transcoded HLS playback
When a browser lacks native HEVC/H.265 support, the frontend now
requests a server-side transcoded HLS stream instead of the original
VOD path. FFmpeg consumes Frigate's existing HLS endpoint and outputs
H.264 MPEG-TS segments to /tmp/stream/transcode/, which nginx already
serves via the /stream/ location.
- New API endpoint: GET /api/transcode/{camera}/start/{ts}/end/{ts}
- Spawns FFmpeg to transcode existing H.265 HLS to H.264 HLS
- Deduplicates sessions by camera + time range
- Limits concurrent transcodes (default 3)
- Waits for first segment before returning playlist URL
- Cleanup: stale transcode sessions removed every 60s (10min TTL)
- Frontend: isHevcSupported() detects browser codec support via MSE
and native video element, transparently falls back to transcoded
playback in DynamicVideoPlayer and TrackingDetails
- Zero changes to existing HLS/VOD path or clip download endpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
74c89beaf9
commit
96360a08d5
@ -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
|
||||
|
||||
279
frigate/api/transcode.py
Normal file
279
frigate/api/transcode.py
Normal file
@ -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})
|
||||
@ -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()
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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<FrigateConfig>("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
|
||||
|
||||
65
web/src/hooks/use-transcoded-playback.ts
Normal file
65
web/src/hooks/use-transcoded-playback.ts
Normal file
@ -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<string | null>(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<TranscodeResponse>(
|
||||
`${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 };
|
||||
}
|
||||
39
web/src/utils/codecUtil.ts
Normal file
39
web/src/utils/codecUtil.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user