mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 14:47:40 +03:00
Merge f67297ed58 into b6c03c99de
This commit is contained in:
commit
2336e5aba4
@ -27,6 +27,7 @@ from frigate.api import (
|
|||||||
preview,
|
preview,
|
||||||
record,
|
record,
|
||||||
review,
|
review,
|
||||||
|
transcode,
|
||||||
)
|
)
|
||||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
@ -142,6 +143,7 @@ def create_fastapi_app(
|
|||||||
app.include_router(media.router)
|
app.include_router(media.router)
|
||||||
app.include_router(motion_search.router)
|
app.include_router(motion_search.router)
|
||||||
app.include_router(record.router)
|
app.include_router(record.router)
|
||||||
|
app.include_router(transcode.router)
|
||||||
app.include_router(debug_replay.router)
|
app.include_router(debug_replay.router)
|
||||||
# App Properties
|
# App Properties
|
||||||
app.frigate_config = frigate_config
|
app.frigate_config = frigate_config
|
||||||
|
|||||||
264
frigate/api/transcode.py
Normal file
264
frigate/api/transcode.py
Normal 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})
|
||||||
@ -208,6 +208,37 @@ PRESETS_HW_ACCEL_ENCODE_PREVIEW = {
|
|||||||
"default": "{0} -hide_banner {1} -c:v libx264 -profile:v baseline -preset:v ultrafast {2}",
|
"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(
|
def parse_preset_hardware_acceleration_decode(
|
||||||
arg: Any,
|
arg: Any,
|
||||||
@ -251,6 +282,7 @@ class EncodeTypeEnum(str, Enum):
|
|||||||
birdseye = "birdseye"
|
birdseye = "birdseye"
|
||||||
preview = "preview"
|
preview = "preview"
|
||||||
timelapse = "timelapse"
|
timelapse = "timelapse"
|
||||||
|
transcode = "transcode"
|
||||||
|
|
||||||
|
|
||||||
def parse_preset_hardware_acceleration_encode(
|
def parse_preset_hardware_acceleration_encode(
|
||||||
@ -267,6 +299,8 @@ def parse_preset_hardware_acceleration_encode(
|
|||||||
arg_map = PRESETS_HW_ACCEL_ENCODE_PREVIEW
|
arg_map = PRESETS_HW_ACCEL_ENCODE_PREVIEW
|
||||||
elif type == EncodeTypeEnum.timelapse:
|
elif type == EncodeTypeEnum.timelapse:
|
||||||
arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE
|
arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE
|
||||||
|
elif type == EncodeTypeEnum.transcode:
|
||||||
|
arg_map = PRESETS_HW_ACCEL_ENCODE_TRANSCODE
|
||||||
|
|
||||||
if not isinstance(arg, str):
|
if not isinstance(arg, str):
|
||||||
return arg_map["default"].format(ffmpeg_path, input, output)
|
return arg_map["default"].format(ffmpeg_path, input, output)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
|
||||||
|
from frigate.api.transcode import cleanup_stale_transcode_sessions
|
||||||
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
|
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
|
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
|
||||||
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
|
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
|
||||||
@ -375,6 +376,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
break
|
break
|
||||||
|
|
||||||
self.clean_tmp_previews()
|
self.clean_tmp_previews()
|
||||||
|
cleanup_stale_transcode_sessions()
|
||||||
|
|
||||||
if counter == 0:
|
if counter == 0:
|
||||||
self.clean_tmp_clips()
|
self.clean_tmp_clips()
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator
|
|||||||
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { VideoResolutionType } from "@/types/live";
|
import { VideoResolutionType } from "@/types/live";
|
||||||
|
import { useTranscodedPlayback } from "@/hooks/use-transcoded-playback";
|
||||||
|
|
||||||
type TrackingDetailsProps = {
|
type TrackingDetailsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -513,7 +514,14 @@ export function TrackingDetails({
|
|||||||
setBlueLineHeightPx(bluePx);
|
setBlueLineHeightPx(bluePx);
|
||||||
}, [eventSequence, timelineSize.width, timelineSize.height, effectiveTime]);
|
}, [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
|
// event.start_time and event.end_time are in DETECT stream time
|
||||||
// Convert to record stream time, then create video clip with padding.
|
// Convert to record stream time, then create video clip with padding.
|
||||||
// Use sourceOffsetRef (stable per event) so the HLS player doesn't
|
// 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;
|
(event.end_time ?? Date.now() / 1000) + sourceOffset / 1000;
|
||||||
const startTime = eventStartRec - REVIEW_PADDING;
|
const startTime = eventStartRec - REVIEW_PADDING;
|
||||||
const endTime = eventEndRec + 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 {
|
resolvePlaylistUrl(vodUrl, event.camera, startTime, endTime).then(
|
||||||
playlist,
|
(playlist: string) => {
|
||||||
startPosition: 0,
|
setVideoSource({ playlist, startPosition: 0 });
|
||||||
};
|
},
|
||||||
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [event]);
|
}, [event]);
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
calculateSeekPosition,
|
calculateSeekPosition,
|
||||||
} from "@/utils/videoUtil";
|
} from "@/utils/videoUtil";
|
||||||
import { isFirefox } from "react-device-detect";
|
import { isFirefox } from "react-device-detect";
|
||||||
|
import { useTranscodedPlayback } from "@/hooks/use-transcoded-playback";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamically switches between video playback and scrubbing preview player.
|
* Dynamically switches between video playback and scrubbing preview player.
|
||||||
@ -71,6 +72,7 @@ export default function DynamicVideoPlayer({
|
|||||||
const { t } = useTranslation(["components/player"]);
|
const { t } = useTranslation(["components/player"]);
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const { resolvePlaylistUrl } = useTranscodedPlayback(apiHost);
|
||||||
|
|
||||||
// for detail stream context in History
|
// for detail stream context in History
|
||||||
const {
|
const {
|
||||||
@ -219,9 +221,15 @@ export default function DynamicVideoPlayer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSource({
|
const vodUrl = `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`;
|
||||||
playlist: `${apiHost}vod/${camera}/start/${recordingParams.after}/end/${recordingParams.before}/master.m3u8`,
|
|
||||||
startPosition,
|
resolvePlaylistUrl(
|
||||||
|
vodUrl,
|
||||||
|
camera,
|
||||||
|
recordingParams.after,
|
||||||
|
recordingParams.before,
|
||||||
|
).then((playlist: string) => {
|
||||||
|
setSource({ playlist, startPosition });
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 };
|
||||||
|
}
|
||||||
40
web/src/utils/codecUtil.ts
Normal file
40
web/src/utils/codecUtil.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user