This commit is contained in:
Tobias Faber 2026-03-22 18:32:11 +00:00 committed by GitHub
commit 2336e5aba4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 433 additions and 9 deletions

View File

@ -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

264
frigate/api/transcode.py Normal file
View File

@ -0,0 +1,264 @@
"""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
from frigate.ffmpeg_presets import (
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
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")
hwaccel_args = config.ffmpeg.hwaccel_args
input_args = (
f"-loglevel warning -y -protocol_whitelist pipe,file,http,tcp -i {input_url}"
)
output_args = (
f"-c:a aac -b:a 128k"
f" -f hls -hls_time 6 -hls_list_size 0"
f" -hls_segment_type mpegts"
f" -hls_flags independent_segments+append_list"
f" -hls_segment_filename {segment_pattern}"
f" {output_playlist}"
)
cmd_str = parse_preset_hardware_acceleration_encode(
config.ffmpeg.ffmpeg_path,
hwaccel_args,
input_args,
output_args,
EncodeTypeEnum.transcode,
)
return cmd_str.split(" ")
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})

View File

@ -208,6 +208,37 @@ PRESETS_HW_ACCEL_ENCODE_PREVIEW = {
"default": "{0} -hide_banner {1} -c:v libx264 -profile:v baseline -preset:v ultrafast {2}",
}
# Presets for on-demand H.265 to H.264 transcode playback
PRESETS_HW_ACCEL_ENCODE_TRANSCODE = {
# Based on birdseye presets with 720p scaling added
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -vf scale=-2:'min(720,ih)' {2}",
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m -vf scale=-2:'min(720,ih)' {2}",
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi -bf 0 -profile:v high -level:v 4.1 -vf format=vaapi|nv12,hwupload,scale_vaapi=w=-2:h=720 {2}",
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 -vf scale=-2:'min(720,ih)' {2}",
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 -vf scale=-2:'min(720,ih)' {2}",
FFMPEG_HWACCEL_NVIDIA: "{0} -hide_banner -hwaccel cuda -hwaccel_output_format cuda -extra_hw_frames 8 {1} -c:v h264_nvenc -profile:v high -preset:v p2 -tune:v ll -vf scale_cuda=w=-2:h=720 {2}",
"preset-jetson-h264": "{0} -hide_banner {1} -c:v h264_nvmpi -profile high -vf scale=-2:'min(720,ih)' {2}",
"preset-jetson-h265": "{0} -hide_banner {1} -c:v h264_nvmpi -profile high -vf scale=-2:'min(720,ih)' {2}",
FFMPEG_HWACCEL_RKMPP: "{0} -hide_banner {1} -c:v h264_rkmpp -profile:v high -vf scale=-2:'min(720,ih)' {2}",
FFMPEG_HWACCEL_AMF: "{0} -hide_banner {1} -c:v h264_amf -profile:v high -vf scale=-2:'min(720,ih)' {2}",
"default": "{0} -hide_banner {1} -c:v libx264 -preset:v ultrafast -crf 26 -profile:v high -level:v 4.1 -pix_fmt yuv420p -vf scale=-2:'min(720,ih)' {2}",
}
PRESETS_HW_ACCEL_ENCODE_TRANSCODE["preset-nvidia-h264"] = (
PRESETS_HW_ACCEL_ENCODE_TRANSCODE[FFMPEG_HWACCEL_NVIDIA]
)
PRESETS_HW_ACCEL_ENCODE_TRANSCODE["preset-nvidia-h265"] = (
PRESETS_HW_ACCEL_ENCODE_TRANSCODE[FFMPEG_HWACCEL_NVIDIA]
)
PRESETS_HW_ACCEL_ENCODE_TRANSCODE[f"{FFMPEG_HWACCEL_RKMPP}-no-dump_extra"] = (
PRESETS_HW_ACCEL_ENCODE_TRANSCODE[FFMPEG_HWACCEL_RKMPP]
)
PRESETS_HW_ACCEL_ENCODE_TRANSCODE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_TRANSCODE[
FFMPEG_HWACCEL_RKMPP
]
PRESETS_HW_ACCEL_ENCODE_TRANSCODE["preset-rk-h265"] = PRESETS_HW_ACCEL_ENCODE_TRANSCODE[
FFMPEG_HWACCEL_RKMPP
]
def parse_preset_hardware_acceleration_decode(
arg: Any,
@ -251,6 +282,7 @@ class EncodeTypeEnum(str, Enum):
birdseye = "birdseye"
preview = "preview"
timelapse = "timelapse"
transcode = "transcode"
def parse_preset_hardware_acceleration_encode(
@ -267,6 +299,8 @@ def parse_preset_hardware_acceleration_encode(
arg_map = PRESETS_HW_ACCEL_ENCODE_PREVIEW
elif type == EncodeTypeEnum.timelapse:
arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE
elif type == EncodeTypeEnum.transcode:
arg_map = PRESETS_HW_ACCEL_ENCODE_TRANSCODE
if not isinstance(arg, str):
return arg_map["default"].format(ffmpeg_path, input, output)

View File

@ -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()

View File

@ -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]);

View File

@ -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

View 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 };
}

View File

@ -0,0 +1,40 @@
/**
* 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 (localStorage.getItem("forceTranscode") === "true") return false;
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;
}