Add HLS browser preview for live RTSP demo and real camera URLs.
MediaMTX pulls site-config RTSP into HLS with API proxying for hls.js on the demo client; simulated realtime keeps local file previews only. Also add optional JPEG frame capture and document Docker HLS host settings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -89,6 +89,10 @@ POSTGRES_PORT=45432
|
||||
# 链路 2(模拟实时)与链路 3(离线 batch)需开启;链路 1 真 RTSP 开录不依赖此项
|
||||
# DEMO_ORCHESTRATOR_ENABLED=false
|
||||
# DEMO_ORCHESTRATOR_RTSP_PORT=18554
|
||||
# 联调台 HLS 预览(MediaMTX 8888 映射到宿主机端口;API 反代给浏览器)
|
||||
# DEMO_HLS_PREVIEW_PORT=18888
|
||||
# API 在 Docker 内时设为 host.docker.internal
|
||||
# DEMO_HLS_PREVIEW_HOST=127.0.0.1
|
||||
# Docker 内 API 访问宿主机假流时写入站点 JSON 的主机名(默认 host.docker.internal)
|
||||
# DOCKER_DEMO_ORCHESTRATOR_RTSP_JSON_HOST=host.docker.internal
|
||||
# 链路 2 simulated-start / fake_rtsp_from_file 起 MediaMTX 容器用
|
||||
|
||||
@@ -9,11 +9,13 @@ from fastapi import (
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
UploadFile,
|
||||
WebSocket,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
@@ -34,7 +36,10 @@ from app.schemas import (
|
||||
build_consumption_summary,
|
||||
)
|
||||
from app.services.recording_live import accept_live_recording
|
||||
from app.services.hls_preview import HlsPreviewManager, fetch_hls_upstream
|
||||
from app.services.rtsp_preview import capture_rtsp_jpeg_frame
|
||||
from app.services.surgery_pipeline import SurgeryPipeline
|
||||
from app.services.video.backend_resolver import BackendResolver
|
||||
from app.services.voice_terminal_hub import VoiceTerminalHub
|
||||
from app.surgery_errors import SurgeryPipelineError
|
||||
|
||||
@@ -152,9 +157,244 @@ async def recording_modes_status() -> dict:
|
||||
"or_site_config_json_file": f or None,
|
||||
"orchestrator_rtsp_port": settings.demo_orchestrator_rtsp_port,
|
||||
"orchestrator_rtsp_json_host": settings.demo_orchestrator_rtsp_json_host,
|
||||
"hls_preview_port": settings.demo_hls_preview_port,
|
||||
}
|
||||
|
||||
|
||||
class HlsPreviewEnsureRequest(BaseModel):
|
||||
camera_ids: list[str] = Field(min_length=1, description="需要预览的 camera_id 列表(仅链路 1 真 RTSP)")
|
||||
|
||||
|
||||
class HlsPreviewCameraInfo(BaseModel):
|
||||
camera_id: str
|
||||
playlist_url: str
|
||||
|
||||
|
||||
class HlsPreviewEnsureResponse(BaseModel):
|
||||
cameras: list[HlsPreviewCameraInfo]
|
||||
hls_preview_port: int
|
||||
|
||||
|
||||
def _hls_playlist_proxy_path(camera_id: str) -> str:
|
||||
return f"/internal/demo/hls-preview/{camera_id}/index.m3u8"
|
||||
|
||||
|
||||
def _rewrite_hls_playlist(body: bytes, *, camera_id: str, proxy_origin: str) -> bytes:
|
||||
sess = HlsPreviewManager.active()
|
||||
if sess is None:
|
||||
return body
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return body
|
||||
if "#EXTM3U" not in text:
|
||||
return body
|
||||
mtx_path = sess.path_by_camera[camera_id]
|
||||
upstream = f"http://{sess.hls_host}:{sess.hls_port}/{mtx_path}"
|
||||
proxy_base = f"{proxy_origin.rstrip('/')}/internal/demo/hls-preview/{camera_id}"
|
||||
text = text.replace(upstream, proxy_base)
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/internal/demo/hls-preview/ensure",
|
||||
response_model=HlsPreviewEnsureResponse,
|
||||
tags=["demo"],
|
||||
summary="Demo:启动/登记 HLS 预览(MediaMTX)",
|
||||
)
|
||||
async def hls_preview_ensure(
|
||||
payload: HlsPreviewEnsureRequest,
|
||||
request: Request,
|
||||
) -> HlsPreviewEnsureResponse:
|
||||
if not (settings.or_site_config_json_file or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="OR_SITE_CONFIG_JSON_FILE is not set",
|
||||
)
|
||||
hls_host = (settings.demo_hls_preview_host or "127.0.0.1").strip() or "127.0.0.1"
|
||||
hls_port = int(settings.demo_hls_preview_port)
|
||||
|
||||
resolver = BackendResolver(settings, hikvision_runtime=None)
|
||||
sources: dict[str, str] = {}
|
||||
for cid in payload.camera_ids:
|
||||
cam = cid.strip()
|
||||
if not cam:
|
||||
continue
|
||||
try:
|
||||
sources[cam] = resolver.rtsp_url_for_camera(cam)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
if not sources:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="camera_ids is empty",
|
||||
)
|
||||
|
||||
def _start() -> None:
|
||||
HlsPreviewManager.start_pull(
|
||||
sources=sources,
|
||||
hls_host=hls_host,
|
||||
hls_port=hls_port,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_start)
|
||||
except Exception as exc:
|
||||
logger.exception("HLS preview start failed: {}", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"HLS preview start failed: {exc}",
|
||||
) from exc
|
||||
|
||||
sess = HlsPreviewManager.active()
|
||||
if sess is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="HLS preview session not available",
|
||||
)
|
||||
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
cameras: list[HlsPreviewCameraInfo] = []
|
||||
for cid in payload.camera_ids:
|
||||
cam = cid.strip()
|
||||
if cam not in sess.path_by_camera:
|
||||
continue
|
||||
cameras.append(
|
||||
HlsPreviewCameraInfo(
|
||||
camera_id=cam,
|
||||
playlist_url=origin + _hls_playlist_proxy_path(cam),
|
||||
)
|
||||
)
|
||||
if not cameras:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="no HLS preview paths for requested camera_ids",
|
||||
)
|
||||
return HlsPreviewEnsureResponse(cameras=cameras, hls_preview_port=hls_port)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/internal/demo/hls-preview/stop",
|
||||
tags=["demo"],
|
||||
summary="Demo:停止 HLS 预览 MediaMTX(拉流模式)",
|
||||
)
|
||||
async def hls_preview_stop() -> dict[str, str]:
|
||||
def _stop() -> None:
|
||||
HlsPreviewManager.stop()
|
||||
|
||||
await asyncio.to_thread(_stop)
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/internal/demo/hls-preview/{camera_id}/{path:path}",
|
||||
tags=["demo"],
|
||||
summary="Demo:反代 MediaMTX HLS(m3u8 / ts)",
|
||||
)
|
||||
async def hls_preview_proxy(
|
||||
camera_id: Annotated[str, Path(min_length=1, max_length=64)],
|
||||
path: str,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
sess = HlsPreviewManager.active()
|
||||
if sess is None or camera_id not in sess.path_by_camera:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="HLS preview not active for this camera_id",
|
||||
)
|
||||
subpath = path.strip() or "index.m3u8"
|
||||
try:
|
||||
upstream = HlsPreviewManager.resolve_upstream_url(camera_id, subpath)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown camera") from exc
|
||||
|
||||
def _fetch() -> tuple[bytes, str]:
|
||||
return fetch_hls_upstream(upstream)
|
||||
|
||||
try:
|
||||
body, ctype = await asyncio.to_thread(_fetch)
|
||||
except Exception as exc:
|
||||
logger.warning("HLS proxy fetch failed {}: {}", upstream, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"HLS upstream fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
if subpath.endswith(".m3u8") or "mpegurl" in ctype:
|
||||
body = _rewrite_hls_playlist(
|
||||
body,
|
||||
camera_id=camera_id,
|
||||
proxy_origin=str(request.base_url).rstrip("/"),
|
||||
)
|
||||
ctype = "application/vnd.apple.mpegurl"
|
||||
|
||||
return Response(
|
||||
content=body,
|
||||
media_type=ctype,
|
||||
headers={"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/internal/demo/rtsp-preview/{camera_id}/frame.jpg",
|
||||
tags=["demo"],
|
||||
summary="Demo:抓取一路 RTSP 当前帧(JPEG)",
|
||||
description=(
|
||||
"供联调台在链路 1/2 中预览各 camera_id 画面;从 OR_SITE_CONFIG_JSON_FILE 解析 RTSP URL。"
|
||||
"浏览器无法直接播放 RTSP,故由服务端拉流并返回单帧 JPEG。"
|
||||
),
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {"description": "无该 camera_id 的 RTSP 映射"},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "拉流或编码失败"},
|
||||
},
|
||||
)
|
||||
async def rtsp_preview_frame(
|
||||
camera_id: Annotated[
|
||||
str,
|
||||
Path(
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$",
|
||||
description="站点 JSON video_rtsp_urls 中的 camera_id。",
|
||||
),
|
||||
],
|
||||
) -> Response:
|
||||
if not (settings.or_site_config_json_file or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="OR_SITE_CONFIG_JSON_FILE is not set",
|
||||
)
|
||||
resolver = BackendResolver(settings, hikvision_runtime=None)
|
||||
try:
|
||||
rtsp_url = resolver.rtsp_url_for_camera(camera_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
def _capture() -> bytes:
|
||||
return capture_rtsp_jpeg_frame(rtsp_url)
|
||||
|
||||
try:
|
||||
jpeg = await asyncio.to_thread(_capture)
|
||||
except Exception as exc:
|
||||
logger.warning("rtsp preview failed camera_id={}: {}", camera_id, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"rtsp preview failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return Response(
|
||||
content=jpeg,
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/client/surgeries/start",
|
||||
response_model=SurgeryApiResponse,
|
||||
|
||||
@@ -187,6 +187,11 @@ class Settings(BaseSettings):
|
||||
demo_orchestrator_enabled: bool = False
|
||||
demo_orchestrator_rtsp_port: int = Field(default=18554, ge=1, le=65535)
|
||||
demo_orchestrator_rtsp_json_host: str = "host.docker.internal"
|
||||
demo_hls_preview_port: int = Field(default=18888, ge=1, le=65535)
|
||||
demo_hls_preview_host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description="MediaMTX HLS 监听地址;API 反代目标。容器内 API 可设为 host.docker.internal。",
|
||||
)
|
||||
|
||||
#: 视觉管线记账默认 ``doctor_id``(TSV 未提供医生列时的回退);语音确认仍为独立常量 ``voice``。
|
||||
video_result_doctor_id: str = Field(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"video_rtsp_urls": {
|
||||
"or-cam-03": "rtsp://127.0.0.1:18554/demo1"
|
||||
"or-cam-01": "rtsp://admin:Aa183137@192.168.3.2:554/Streaming/Channels/101",
|
||||
"or-cam-02": "rtsp://admin:Aa183137@192.168.3.3:554/Streaming/Channels/101",
|
||||
"or-cam-03": "rtsp://admin:Aa183137@192.168.3.4:554/Streaming/Channels/101",
|
||||
"or-cam-04": "rtsp://admin:Aa183137@192.168.3.5:554/Streaming/Channels/101"
|
||||
},
|
||||
"voice_or_room_bindings": [
|
||||
{
|
||||
|
||||
191
backend/app/services/hls_preview.py
Normal file
191
backend/app/services/hls_preview.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Demo 联调:MediaMTX 将 RTSP 转为 HLS,由 API 反代给浏览器(hls.js)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.services.synthetic_rtsp import MEDIAMTX_IMAGE
|
||||
|
||||
CONTAINER_NAME_PREFIX = "orm-hls-preview-"
|
||||
_MEDIAMTX_HLS_INTERNAL_PORT = 8888
|
||||
_PATH_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||
|
||||
|
||||
def sanitize_mediamtx_path(name: str) -> str:
|
||||
"""MediaMTX 路径名:优先用 camera_id,仅保留安全字符。"""
|
||||
raw = (name or "").strip() or "live"
|
||||
cleaned = _PATH_SAFE.sub("_", raw)
|
||||
return cleaned or "live"
|
||||
|
||||
|
||||
def _wait_tcp(host: str, port: int, *, timeout: float) -> None:
|
||||
deadline = time.monotonic() + max(1.0, timeout)
|
||||
last: OSError | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1.5):
|
||||
return
|
||||
except OSError as exc:
|
||||
last = exc
|
||||
time.sleep(0.2)
|
||||
msg = f"等待 {host}:{port} 超时({timeout:g}s)"
|
||||
if last is not None:
|
||||
raise RuntimeError(f"{msg}: {last}") from last
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _write_mediamtx_config(path: Path, paths: dict[str, str]) -> None:
|
||||
"""``paths``: mediamtx 路径名 -> RTSP source URL(拉流)。"""
|
||||
lines = [
|
||||
"# Generated for OR demo HLS preview",
|
||||
"pathDefaults:",
|
||||
" sourceOnDemand: yes",
|
||||
"paths:",
|
||||
]
|
||||
for mtx_path, source in paths.items():
|
||||
lines.append(f" {mtx_path}:")
|
||||
lines.append(f" source: {source}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HlsPreviewSession:
|
||||
hls_host: str
|
||||
hls_port: int
|
||||
path_by_camera: dict[str, str]
|
||||
container_name: str | None = None
|
||||
config_dir: Path | None = None
|
||||
|
||||
def upstream_base(self, camera_id: str) -> str:
|
||||
mtx = self.path_by_camera[camera_id]
|
||||
return f"http://{self.hls_host}:{self.hls_port}/{mtx}"
|
||||
|
||||
|
||||
class HlsPreviewManager:
|
||||
_active: ClassVar[HlsPreviewSession | None] = None
|
||||
|
||||
@classmethod
|
||||
def active(cls) -> HlsPreviewSession | None:
|
||||
return cls._active
|
||||
|
||||
@classmethod
|
||||
def stop(cls) -> None:
|
||||
sess = cls._active
|
||||
if sess is None:
|
||||
return
|
||||
if sess.container_name and shutil.which("docker"):
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", sess.container_name],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.debug("hls preview docker rm: {}", exc)
|
||||
if sess.config_dir and sess.config_dir.is_dir():
|
||||
shutil.rmtree(sess.config_dir, ignore_errors=True)
|
||||
cls._active = None
|
||||
|
||||
@classmethod
|
||||
def start_pull(
|
||||
cls,
|
||||
*,
|
||||
sources: dict[str, str],
|
||||
hls_host: str,
|
||||
hls_port: int,
|
||||
ready_timeout_sec: float = 30.0,
|
||||
) -> HlsPreviewSession:
|
||||
"""链路 1:独立 MediaMTX 容器按路径拉取各 RTSP。"""
|
||||
if not sources:
|
||||
raise ValueError("no camera sources for HLS preview")
|
||||
if not shutil.which("docker"):
|
||||
raise RuntimeError("docker not in PATH (required for HLS preview MediaMTX)")
|
||||
|
||||
cls.stop()
|
||||
|
||||
path_by_camera: dict[str, str] = {}
|
||||
mtx_paths: dict[str, str] = {}
|
||||
for camera_id, rtsp_url in sources.items():
|
||||
mtx = sanitize_mediamtx_path(camera_id)
|
||||
path_by_camera[camera_id] = mtx
|
||||
mtx_paths[mtx] = rtsp_url
|
||||
|
||||
work = Path(os.environ.get("TMPDIR", "/tmp")) / f"orm-hls-preview-{uuid.uuid4().hex[:10]}"
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
cfg = work / "mediamtx.yml"
|
||||
_write_mediamtx_config(cfg, mtx_paths)
|
||||
|
||||
container = CONTAINER_NAME_PREFIX + uuid.uuid4().hex[:12]
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container,
|
||||
"-v",
|
||||
f"{cfg.resolve()}:/mediamtx.yml:ro",
|
||||
"-p",
|
||||
f"{hls_host}:{hls_port}:{_MEDIAMTX_HLS_INTERNAL_PORT}",
|
||||
MEDIAMTX_IMAGE,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
err = (r.stderr or r.stdout or "").strip()
|
||||
raise RuntimeError(f"HLS preview MediaMTX failed: {err}")
|
||||
|
||||
try:
|
||||
_wait_tcp(hls_host, hls_port, timeout=ready_timeout_sec)
|
||||
except Exception:
|
||||
subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False)
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
raise
|
||||
|
||||
sess = HlsPreviewSession(
|
||||
hls_host=hls_host,
|
||||
hls_port=hls_port,
|
||||
path_by_camera=path_by_camera,
|
||||
container_name=container,
|
||||
config_dir=work,
|
||||
)
|
||||
cls._active = sess
|
||||
logger.info(
|
||||
"HLS preview (pull) started container={} cameras={} hls=http://{}:{}/",
|
||||
container,
|
||||
list(path_by_camera),
|
||||
hls_host,
|
||||
hls_port,
|
||||
)
|
||||
return sess
|
||||
|
||||
@classmethod
|
||||
def resolve_upstream_url(cls, camera_id: str, subpath: str) -> str:
|
||||
sess = cls._active
|
||||
if sess is None or camera_id not in sess.path_by_camera:
|
||||
raise KeyError(camera_id)
|
||||
base = sess.upstream_base(camera_id).rstrip("/")
|
||||
sub = (subpath or "").lstrip("/")
|
||||
return f"{base}/{sub}" if sub else base
|
||||
|
||||
|
||||
def fetch_hls_upstream(url: str) -> tuple[bytes, str]:
|
||||
"""同步拉取 MediaMTX HLS 资源(m3u8 / ts)。"""
|
||||
req = Request(url, headers={"User-Agent": "operation-room-monitor-hls-proxy/1.0"})
|
||||
with urlopen(req, timeout=15) as resp: # noqa: S310
|
||||
body = resp.read()
|
||||
ctype = resp.headers.get_content_type() or "application/octet-stream"
|
||||
return body, ctype
|
||||
32
backend/app/services/rtsp_preview.py
Normal file
32
backend/app/services/rtsp_preview.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Demo 用:从 RTSP 抓取单帧 JPEG,供浏览器预览(浏览器无法直接播放 RTSP)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
|
||||
|
||||
def capture_rtsp_jpeg_frame(
|
||||
rtsp_url: str,
|
||||
*,
|
||||
open_timeout_ms: int = 8000,
|
||||
jpeg_quality: int = 75,
|
||||
) -> bytes:
|
||||
"""打开 RTSP、读取一帧并编码为 JPEG;失败时抛出 RuntimeError。"""
|
||||
cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
|
||||
try:
|
||||
if hasattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC"):
|
||||
cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, float(open_timeout_ms))
|
||||
if hasattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC"):
|
||||
cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, float(open_timeout_ms))
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"无法打开 RTSP:{rtsp_url!r}")
|
||||
ok, frame = cap.read()
|
||||
if not ok or frame is None:
|
||||
raise RuntimeError(f"RTSP 未读到帧:{rtsp_url!r}")
|
||||
quality = max(1, min(100, int(jpeg_quality)))
|
||||
ok_enc, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
|
||||
if not ok_enc:
|
||||
raise RuntimeError("JPEG 编码失败")
|
||||
return buf.tobytes()
|
||||
finally:
|
||||
cap.release()
|
||||
@@ -88,6 +88,8 @@ services:
|
||||
DEMO_ORCHESTRATOR_ENABLED: ${DEMO_ORCHESTRATOR_ENABLED:-false}
|
||||
DEMO_ORCHESTRATOR_RTSP_PORT: ${DEMO_ORCHESTRATOR_RTSP_PORT:-18554}
|
||||
DEMO_ORCHESTRATOR_RTSP_JSON_HOST: ${DOCKER_DEMO_ORCHESTRATOR_RTSP_JSON_HOST:-host.docker.internal}
|
||||
DEMO_HLS_PREVIEW_PORT: ${DEMO_HLS_PREVIEW_PORT:-18888}
|
||||
DEMO_HLS_PREVIEW_HOST: ${DOCKER_DEMO_HLS_PREVIEW_HOST:-host.docker.internal}
|
||||
MEDIAMTX_DOCKER_IMAGE: ${MEDIAMTX_DOCKER_IMAGE:-m.daocloud.io/docker.io/bluenviron/mediamtx:latest}
|
||||
command: >
|
||||
sh -c "uv run --no-sync alembic upgrade head &&
|
||||
|
||||
@@ -72,6 +72,9 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
from app.services.hls_preview import HlsPreviewManager
|
||||
|
||||
HlsPreviewManager.stop()
|
||||
await container.shutdown()
|
||||
await engine.dispose()
|
||||
logger.info("Database engine disposed")
|
||||
|
||||
69
backend/tests/test_hls_preview_api.py
Normal file
69
backend/tests/test_hls_preview_api.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""HLS 预览 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import router as api_router
|
||||
from app.config import Settings
|
||||
from app.services.hls_preview import HlsPreviewManager, HlsPreviewSession
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hls_client(tmp_path) -> TestClient:
|
||||
site = tmp_path / "site.json"
|
||||
site.write_text(
|
||||
'{"video_rtsp_urls":{"or-cam-01":"rtsp://cam/1"},'
|
||||
'"voice_or_room_bindings":[]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(api_router)
|
||||
with patch("app.api.settings", Settings(or_site_config_json_file=str(site))):
|
||||
yield TestClient(app)
|
||||
HlsPreviewManager.stop()
|
||||
|
||||
|
||||
def test_hls_preview_ensure_pull(hls_client: TestClient) -> None:
|
||||
fake_sess = HlsPreviewSession(
|
||||
hls_host="127.0.0.1",
|
||||
hls_port=18888,
|
||||
path_by_camera={"or-cam-01": "or-cam-01"},
|
||||
)
|
||||
|
||||
def _fake_start(**_kwargs: object) -> HlsPreviewSession:
|
||||
HlsPreviewManager._active = fake_sess
|
||||
return fake_sess
|
||||
|
||||
with patch.object(HlsPreviewManager, "start_pull", side_effect=_fake_start):
|
||||
r = hls_client.post(
|
||||
"/internal/demo/hls-preview/ensure",
|
||||
json={"camera_ids": ["or-cam-01"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["cameras"][0]["camera_id"] == "or-cam-01"
|
||||
assert "index.m3u8" in body["cameras"][0]["playlist_url"]
|
||||
|
||||
|
||||
def test_hls_proxy_rewrites_playlist(hls_client: TestClient) -> None:
|
||||
HlsPreviewManager._active = HlsPreviewSession(
|
||||
hls_host="127.0.0.1",
|
||||
hls_port=18888,
|
||||
path_by_camera={"or-cam-01": "or-cam-01"},
|
||||
)
|
||||
playlist = (
|
||||
"#EXTM3U\n#EXT-X-TARGETDURATION:1\n"
|
||||
"http://127.0.0.1:18888/or-cam-01/segment.ts\n"
|
||||
).encode()
|
||||
|
||||
with patch("app.api.fetch_hls_upstream", return_value=(playlist, "application/vnd.apple.mpegurl")):
|
||||
r = hls_client.get("/internal/demo/hls-preview/or-cam-01/index.m3u8")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
assert "/internal/demo/hls-preview/or-cam-01/segment.ts" in text
|
||||
assert "127.0.0.1:18888" not in text
|
||||
43
backend/tests/test_rtsp_preview_api.py
Normal file
43
backend/tests/test_rtsp_preview_api.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Demo RTSP 预览帧 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import router as api_router
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def preview_client(tmp_path) -> TestClient:
|
||||
site = tmp_path / "site.json"
|
||||
site.write_text(
|
||||
'{"video_rtsp_urls":{"or-cam-01":"rtsp://test/c1"},'
|
||||
'"voice_or_room_bindings":[]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(api_router)
|
||||
with patch("app.api.settings", Settings(or_site_config_json_file=str(site))):
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
def test_rtsp_preview_frame_ok(preview_client: TestClient) -> None:
|
||||
fake_jpeg = b"\xff\xd8\xff\xd9"
|
||||
with patch(
|
||||
"app.api.capture_rtsp_jpeg_frame",
|
||||
return_value=fake_jpeg,
|
||||
):
|
||||
r = preview_client.get("/internal/demo/rtsp-preview/or-cam-01/frame.jpg")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("image/jpeg")
|
||||
assert r.content == fake_jpeg
|
||||
|
||||
|
||||
def test_rtsp_preview_unknown_camera(preview_client: TestClient) -> None:
|
||||
r = preview_client.get("/internal/demo/rtsp-preview/unknown-cam/frame.jpg")
|
||||
assert r.status_code == 404
|
||||
@@ -13,6 +13,8 @@
|
||||
let selectedConsumables = new Set();
|
||||
let lastVideoBatchDoctorDisplay = "";
|
||||
let videoVisToken = 0;
|
||||
const hlsPlayers = {};
|
||||
const slotBlobUrls = {};
|
||||
const webcamSlotState = {};
|
||||
|
||||
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
|
||||
@@ -356,7 +358,8 @@
|
||||
card.classList.toggle("active", card.dataset.mode === mode);
|
||||
});
|
||||
document.querySelectorAll(".mode-panel").forEach((panel) => {
|
||||
panel.classList.toggle("active", panel.dataset.mode === mode);
|
||||
const modes = (panel.dataset.mode || "").split(/\s+/).filter(Boolean);
|
||||
panel.classList.toggle("active", modes.length === 0 || modes.includes(mode));
|
||||
});
|
||||
const voice = $("voice-callout");
|
||||
if (voice) voice.classList.toggle("hidden", mode === "offline-batch");
|
||||
@@ -379,6 +382,204 @@
|
||||
: "链路 3 · 离线精确";
|
||||
if (mode !== "offline-batch") hideVideoBatchVisualization();
|
||||
refreshRecordingModesStatus();
|
||||
if (mode === "live-rtsp") {
|
||||
rebuildPreviewGrid();
|
||||
startPreviewPolling();
|
||||
} else {
|
||||
stopPreviewPolling();
|
||||
}
|
||||
}
|
||||
|
||||
function isRtspPreviewMode() {
|
||||
return activeMode === "live-rtsp";
|
||||
}
|
||||
|
||||
function parseCameraIdsInput() {
|
||||
return ($("camera-ids")?.value || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectPreviewCameras() {
|
||||
if (activeMode !== "live-rtsp") return [];
|
||||
return parseCameraIdsInput().map((id) => ({ id, label: id, source: "rtsp" }));
|
||||
}
|
||||
|
||||
function revokeSlotBlobUrls() {
|
||||
for (const key of Object.keys(slotBlobUrls)) {
|
||||
URL.revokeObjectURL(slotBlobUrls[key]);
|
||||
delete slotBlobUrls[key];
|
||||
}
|
||||
}
|
||||
|
||||
function updateSlotFilePreview(slot, file) {
|
||||
const vid = $("sim-preview-" + slot);
|
||||
if (!vid) return;
|
||||
if (slotBlobUrls[slot]) {
|
||||
URL.revokeObjectURL(slotBlobUrls[slot]);
|
||||
delete slotBlobUrls[slot];
|
||||
}
|
||||
if (!file) {
|
||||
vid.removeAttribute("src");
|
||||
vid.classList.add("hidden");
|
||||
vid.pause();
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
slotBlobUrls[slot] = url;
|
||||
vid.src = url;
|
||||
vid.classList.remove("hidden");
|
||||
vid.loop = true;
|
||||
vid.muted = true;
|
||||
void vid.play().catch(() => {});
|
||||
}
|
||||
|
||||
function rebuildPreviewGrid() {
|
||||
const grid = $("rtsp-preview-grid");
|
||||
if (!grid) return;
|
||||
clearPreviewGridBlobUrls();
|
||||
grid.innerHTML = "";
|
||||
const cameras = collectPreviewCameras();
|
||||
if (!cameras.length) {
|
||||
grid.innerHTML = '<p class="labels-meta">请填写 camera_id(逗号分隔)。</p>';
|
||||
return;
|
||||
}
|
||||
cameras.forEach((cam) => {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "preview-tile";
|
||||
tile.dataset.cameraId = cam.id;
|
||||
tile.dataset.source = "rtsp";
|
||||
const h = document.createElement("h4");
|
||||
h.textContent = cam.label;
|
||||
tile.appendChild(h);
|
||||
const video = document.createElement("video");
|
||||
video.className = "preview-media";
|
||||
video.playsInline = true;
|
||||
video.muted = true;
|
||||
video.autoplay = true;
|
||||
video.controls = true;
|
||||
tile.appendChild(video);
|
||||
const err = document.createElement("div");
|
||||
err.className = "preview-err";
|
||||
tile.appendChild(err);
|
||||
grid.appendChild(tile);
|
||||
});
|
||||
}
|
||||
|
||||
function clearPreviewGridBlobUrls() {
|
||||
const grid = $("rtsp-preview-grid");
|
||||
if (!grid) return;
|
||||
grid.querySelectorAll(".preview-tile").forEach((tile) => {
|
||||
if (tile._blobUrl) {
|
||||
URL.revokeObjectURL(tile._blobUrl);
|
||||
tile._blobUrl = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroyHlsPlayers() {
|
||||
for (const key of Object.keys(hlsPlayers)) {
|
||||
const entry = hlsPlayers[key];
|
||||
try {
|
||||
entry?.hls?.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
delete hlsPlayers[key];
|
||||
}
|
||||
}
|
||||
|
||||
function attachHlsToTile(tile, playlistUrl) {
|
||||
const video = tile.querySelector("video.preview-media");
|
||||
const errEl = tile.querySelector(".preview-err");
|
||||
const camId = tile.dataset.cameraId;
|
||||
if (!video) return;
|
||||
if (typeof Hls === "undefined") {
|
||||
if (errEl) errEl.textContent = "hls.js 未加载";
|
||||
return;
|
||||
}
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
|
||||
hls.on(Hls.Events.ERROR, (_ev, data) => {
|
||||
if (data.fatal && errEl) {
|
||||
errEl.textContent = "HLS 播放失败:" + (data.type || "");
|
||||
}
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (errEl) errEl.textContent = "";
|
||||
void video.play().catch(() => {});
|
||||
});
|
||||
hls.loadSource(playlistUrl);
|
||||
hls.attachMedia(video);
|
||||
if (camId) hlsPlayers[camId] = { hls, video };
|
||||
return;
|
||||
}
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = playlistUrl;
|
||||
void video.play().catch(() => {});
|
||||
if (errEl) errEl.textContent = "";
|
||||
} else if (errEl) {
|
||||
errEl.textContent = "当前浏览器不支持 HLS";
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHlsPreview() {
|
||||
const rtspCams = collectPreviewCameras().filter((c) => c.source === "rtsp");
|
||||
if (!rtspCams.length) return null;
|
||||
const { res, body } = await apiJson("POST", "/internal/demo/hls-preview/ensure", {
|
||||
camera_ids: rtspCams.map((c) => c.id),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(formatDetail(body));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function startPreviewPolling() {
|
||||
stopPreviewPolling({ keepGrid: true });
|
||||
if (!isRtspPreviewMode()) return;
|
||||
const status = $("preview-status");
|
||||
rebuildPreviewGrid();
|
||||
const grid = $("rtsp-preview-grid");
|
||||
try {
|
||||
const hasRtsp = collectPreviewCameras().some((c) => c.source === "rtsp");
|
||||
if (hasRtsp) {
|
||||
if (status) status.textContent = "正在启动 HLS…";
|
||||
const ensureBody = await ensureHlsPreview();
|
||||
const byId = Object.fromEntries(
|
||||
(ensureBody?.cameras || []).map((c) => [c.camera_id, c.playlist_url]),
|
||||
);
|
||||
grid?.querySelectorAll('.preview-tile[data-source="rtsp"]').forEach((tile) => {
|
||||
const id = tile.dataset.cameraId;
|
||||
if (byId[id]) attachHlsToTile(tile, byId[id]);
|
||||
});
|
||||
if (status) status.textContent = "HLS 预览中(MediaMTX)";
|
||||
} else if (status) {
|
||||
status.textContent = "本地文件预览";
|
||||
}
|
||||
} catch (e) {
|
||||
if (status) status.textContent = "预览失败";
|
||||
showBanner("HLS 预览失败:" + String(e), "err");
|
||||
}
|
||||
}
|
||||
|
||||
async function stopPreviewPolling(options = {}) {
|
||||
destroyHlsPlayers();
|
||||
if (!options.keepGrid) {
|
||||
clearPreviewGridBlobUrls();
|
||||
const grid = $("rtsp-preview-grid");
|
||||
if (grid) grid.innerHTML = "";
|
||||
}
|
||||
const status = $("preview-status");
|
||||
if (status && !options.keepGrid) status.textContent = "已停止";
|
||||
if (!options.skipApi) {
|
||||
try {
|
||||
await apiJson("DELETE", "/internal/demo/hls-preview/stop");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
@@ -597,8 +798,9 @@
|
||||
const hint = $("sim-hint-" + slot);
|
||||
const fname = $("sim-fname-" + slot);
|
||||
if (vfile && file) assignFileToInput(vfile, file);
|
||||
if (fname) fname.textContent = file ? file.name : "";
|
||||
if (fname) fname.textContent = file ? file.name : "点击或拖放视频";
|
||||
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
|
||||
updateSlotFilePreview(slot, file || null);
|
||||
}
|
||||
|
||||
function setOfflineFile(file, source) {
|
||||
@@ -986,6 +1188,14 @@
|
||||
if (logEl) logEl.innerHTML = "";
|
||||
});
|
||||
$("base-url")?.addEventListener("change", refreshRecordingModesStatus);
|
||||
$("camera-ids")?.addEventListener("input", () => {
|
||||
if (activeMode === "live-rtsp") startPreviewPolling();
|
||||
});
|
||||
$("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility);
|
||||
$("btn-preview-refresh")?.addEventListener("click", () => startPreviewPolling());
|
||||
$("btn-preview-stop")?.addEventListener("click", () => {
|
||||
void stopPreviewPolling();
|
||||
});
|
||||
|
||||
refreshHealth();
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
<section class="card mode-panel active" data-mode="live-rtsp">
|
||||
<h2>真摄像头配置</h2>
|
||||
<label for="camera-ids">摄像头 ID(逗号分隔)</label>
|
||||
<input id="camera-ids" type="text" value="or-cam-03" placeholder="or-cam-03, or-cam-02" />
|
||||
<p class="labels-meta" style="margin-top:8px">使用站点 JSON 中的真实 RTSP 地址。</p>
|
||||
<input id="camera-ids" type="text" value="or-cam-01, or-cam-02, or-cam-03, or-cam-04" placeholder="or-cam-01, or-cam-02" />
|
||||
<p class="labels-meta" style="margin-top:8px">使用站点 JSON 中的真实 RTSP 地址;下方可预览每路画面。</p>
|
||||
</section>
|
||||
|
||||
<section class="card mode-panel" data-mode="live-simulated">
|
||||
@@ -106,6 +106,7 @@
|
||||
<button type="button" class="secondary" id="sim-webcam-1">摄像头</button>
|
||||
</div>
|
||||
<div class="stream-hint" id="sim-hint-1"></div>
|
||||
<video class="slot-file-preview hidden" id="sim-preview-1" playsinline muted></video>
|
||||
</div>
|
||||
<div class="stream-slot hidden" id="sim-stream-2">
|
||||
<h4>路 2</h4>
|
||||
@@ -119,6 +120,7 @@
|
||||
<button type="button" class="secondary" id="sim-webcam-2">摄像头</button>
|
||||
</div>
|
||||
<div class="stream-hint" id="sim-hint-2"></div>
|
||||
<video class="slot-file-preview hidden" id="sim-preview-2" playsinline muted></video>
|
||||
</div>
|
||||
<div class="stream-slot hidden" id="sim-stream-3">
|
||||
<h4>路 3</h4>
|
||||
@@ -132,6 +134,7 @@
|
||||
<button type="button" class="secondary" id="sim-webcam-3">摄像头</button>
|
||||
</div>
|
||||
<div class="stream-hint" id="sim-hint-3"></div>
|
||||
<video class="slot-file-preview hidden" id="sim-preview-3" playsinline muted></video>
|
||||
</div>
|
||||
<div class="stream-slot hidden" id="sim-stream-4">
|
||||
<h4>路 4</h4>
|
||||
@@ -145,6 +148,7 @@
|
||||
<button type="button" class="secondary" id="sim-webcam-4">摄像头</button>
|
||||
</div>
|
||||
<div class="stream-hint" id="sim-hint-4"></div>
|
||||
<video class="slot-file-preview hidden" id="sim-preview-4" playsinline muted></video>
|
||||
</div>
|
||||
</div>
|
||||
<details class="advanced">
|
||||
@@ -180,6 +184,19 @@
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="card mode-panel" data-mode="live-rtsp">
|
||||
<h2>视频预览(HLS)</h2>
|
||||
<p class="labels-meta">
|
||||
仅链路 1:MediaMTX 将术间 RTSP 转为 HLS,经 API 反代后由 hls.js 播放。模拟实时请用各路槽位下的本地文件预览。
|
||||
</p>
|
||||
<div class="preview-toolbar">
|
||||
<button type="button" class="secondary" id="btn-preview-refresh">启动/刷新预览</button>
|
||||
<button type="button" class="secondary" id="btn-preview-stop">停止预览</button>
|
||||
<span id="preview-status" class="labels-meta">未启动</span>
|
||||
</div>
|
||||
<div id="rtsp-preview-grid" class="preview-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>操作</h2>
|
||||
<div class="steps">
|
||||
@@ -212,6 +229,7 @@
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -435,6 +435,58 @@ details.advanced .advanced-body {
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.slot-file-preview {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
max-height: 140px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #000;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-tile {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.preview-tile h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-tile .preview-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.preview-tile .preview-err {
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger);
|
||||
margin-top: 6px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -57,6 +57,21 @@ SDK **不作为构建期依赖**:将厂商提供的 Linux x86_64 动态库挂
|
||||
- 同类物品写入受 `VIDEO_DETAIL_COOLDOWN_SEC` 节流。
|
||||
- RTSP 读帧连续失败达到 `VIDEO_READ_FAILURE_RECONNECT_THRESHOLD` 时会 `release` 并尝试重连,间隔 `VIDEO_RECONNECT_BACKOFF_SECONDS`。
|
||||
|
||||
## Demo 联调台:HLS 浏览器预览
|
||||
|
||||
浏览器无法直接播放 RTSP。联调台通过 **MediaMTX** 将 RTSP 转为 **HLS**,由 FastAPI 反代 m3u8/ts 后由 **hls.js** 播放。
|
||||
|
||||
| 链路 | 行为 |
|
||||
|------|------|
|
||||
| **链路 1**(真 RTSP) | `POST /internal/demo/hls-preview/ensure` 在宿主机起独立 MediaMTX 容器,按 `or_site_config` 的 `video_rtsp_urls` 拉流 |
|
||||
| **链路 2**(模拟实时) | 不使用 HLS/RTSP 预览;联调台在各路槽位用本地 `<video>` 预览所选文件 |
|
||||
|
||||
- 播放地址形如:`GET /internal/demo/hls-preview/{camera_id}/index.m3u8`(playlist 内分片 URL 会重写为 API 路径)。
|
||||
- **API 在 Docker 内**时须设 `DEMO_HLS_PREVIEW_HOST=host.docker.internal`(compose 已默认),且宿主机需有 **docker** CLI 供拉流模式起 MediaMTX。
|
||||
- 环境变量:`DEMO_HLS_PREVIEW_PORT`(默认 18888)、`DEMO_HLS_PREVIEW_HOST`(本机默认 `127.0.0.1`)。
|
||||
|
||||
单帧 JPEG 预览仍保留:`GET /internal/demo/rtsp-preview/{camera_id}/frame.jpg`(不依赖 MediaMTX)。
|
||||
|
||||
## 相关环境变量
|
||||
|
||||
详见 `backend/.env.example` 中「视频:RTSP + 可选海康 HCNetSDK」一节。
|
||||
详见 `backend/.env.example` 中「视频:RTSP + 可选海康 HCNetSDK」与 HLS 预览相关注释。
|
||||
Reference in New Issue
Block a user