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:
Kevin
2026-05-22 14:44:28 +08:00
parent d6f4590969
commit e92dc1a6d9
14 changed files with 894 additions and 7 deletions

View File

@@ -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 容器用

View File

@@ -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 HLSm3u8 / 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,

View File

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

View File

@@ -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": [
{

View 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

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

View File

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

View File

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

View 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

View 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

View File

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

View File

@@ -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">
仅链路 1MediaMTX 将术间 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>

View File

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

View File

@@ -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 预览相关注释