fix hls
This commit is contained in:
@@ -89,12 +89,12 @@ POSTGRES_PORT=45432
|
||||
# 链路 2(模拟实时)与链路 3(离线 batch)需开启;链路 1 真 RTSP 开录不依赖此项
|
||||
# DEMO_ORCHESTRATOR_ENABLED=false
|
||||
# DEMO_ORCHESTRATOR_RTSP_PORT=18554
|
||||
# 联调台 HLS 预览(MediaMTX 8888 映射到宿主机端口;API 反代给浏览器)
|
||||
# 联调台 HLS 预览(推荐 Compose 侧车 mediamtx-hls,API 只反代)
|
||||
# DEMO_HLS_PREVIEW_UPSTREAM=http://mediamtx-hls:8888
|
||||
# DEMO_HLS_PREVIEW_CONFIG_DIR=/hls-preview-config
|
||||
# 本机无 Compose 时:留空 upstream,临时 docker 容器 + 端口 18888
|
||||
# DEMO_HLS_PREVIEW_PORT=18888
|
||||
# API 在 Docker 内访问宿主机 HLS:host.docker.internal(docker -p 会自动绑定 127.0.0.1)
|
||||
# DEMO_HLS_PREVIEW_HOST=127.0.0.1
|
||||
# 可选:显式指定 docker -p 绑定地址(一般留空)
|
||||
# DEMO_HLS_PREVIEW_BIND_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 容器用
|
||||
|
||||
@@ -36,11 +36,7 @@ from app.schemas import (
|
||||
build_consumption_summary,
|
||||
)
|
||||
from app.services.recording_live import accept_live_recording
|
||||
from app.services.hls_preview import (
|
||||
HlsPreviewManager,
|
||||
docker_publish_bind_host,
|
||||
fetch_hls_upstream,
|
||||
)
|
||||
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
|
||||
@@ -162,6 +158,7 @@ async def recording_modes_status() -> dict:
|
||||
"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,
|
||||
"hls_preview_upstream": (settings.demo_hls_preview_upstream or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -193,8 +190,7 @@ def _rewrite_hls_playlist(body: bytes, *, camera_id: str, proxy_origin: str) ->
|
||||
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}"
|
||||
upstream = sess.camera_upstream_base(camera_id)
|
||||
proxy_base = f"{proxy_origin.rstrip('/')}/internal/demo/hls-preview/{camera_id}"
|
||||
text = text.replace(upstream, proxy_base)
|
||||
return text.encode("utf-8")
|
||||
@@ -215,12 +211,8 @@ async def hls_preview_ensure(
|
||||
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)
|
||||
bind_host = docker_publish_bind_host(
|
||||
hls_host,
|
||||
explicit_bind=(settings.demo_hls_preview_bind_host or "").strip(),
|
||||
)
|
||||
upstream_url = (settings.demo_hls_preview_upstream or "").strip()
|
||||
|
||||
resolver = BackendResolver(settings, hikvision_runtime=None)
|
||||
sources: dict[str, str] = {}
|
||||
@@ -242,11 +234,13 @@ async def hls_preview_ensure(
|
||||
)
|
||||
|
||||
def _start() -> None:
|
||||
HlsPreviewManager.start_pull(
|
||||
HlsPreviewManager.start(
|
||||
sources=sources,
|
||||
hls_host=hls_host,
|
||||
hls_port=hls_port,
|
||||
bind_host=bind_host,
|
||||
upstream_url=upstream_url,
|
||||
config_dir=settings.demo_hls_preview_config_dir,
|
||||
attached_container_name=settings.demo_hls_preview_container_name,
|
||||
ephemeral_access_host=settings.demo_hls_preview_host,
|
||||
ephemeral_port=hls_port,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -188,16 +188,25 @@ class Settings(BaseSettings):
|
||||
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="API 反代访问的 MediaMTX HLS 地址;容器内 API 可设为 host.docker.internal。",
|
||||
)
|
||||
demo_hls_preview_bind_host: str = Field(
|
||||
demo_hls_preview_upstream: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"docker run -p 绑定到宿主机的地址;留空时 host.docker.internal/localhost 自动用 127.0.0.1。"
|
||||
"推荐:Compose 内 MediaMTX 基址,如 http://mediamtx-hls:8888。"
|
||||
"设此项后 API 不再 docker run -p,只写配置并重启 mediamtx-hls。"
|
||||
),
|
||||
)
|
||||
demo_hls_preview_config_dir: str = Field(
|
||||
default="/hls-preview-config",
|
||||
description="与 mediamtx-hls 共享的配置目录(mediamtx.yml)。",
|
||||
)
|
||||
demo_hls_preview_container_name: str = Field(
|
||||
default="orm-mediamtx-hls",
|
||||
description="Compose 中 MediaMTX HLS 预览容器名(docker restart 用)。",
|
||||
)
|
||||
demo_hls_preview_host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description="未设 upstream 时,本机临时容器的访问主机(容器内自动改为 host.docker.internal)。",
|
||||
)
|
||||
|
||||
#: 视觉管线记账默认 ``doctor_id``(TSV 未提供医生列时的回退);语音确认仍为独立常量 ``voice``。
|
||||
video_result_doctor_id: str = Field(
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Demo 联调:MediaMTX 将 RTSP 转为 HLS,由 API 反代给浏览器(hls.js)。"""
|
||||
"""Demo 联调:MediaMTX 将 RTSP 转为 HLS,由 API 反代给浏览器(hls.js)。
|
||||
|
||||
推荐(Docker Compose):固定 ``mediamtx-hls`` 服务 + ``DEMO_HLS_PREVIEW_UPSTREAM=http://mediamtx-hls:8888``,
|
||||
API 只写共享配置并 ``docker restart``,无需 ``host.docker.internal`` / ``-p`` 绑定。
|
||||
|
||||
本机开发(无 upstream):按需 ``docker run`` 临时容器,端口发布在 ``127.0.0.1``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,12 +13,12 @@ import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, Literal
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from loguru import logger
|
||||
@@ -22,24 +28,47 @@ 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._-]+")
|
||||
# docker -p 只接受 IP/0.0.0.0,不能写 host.docker.internal
|
||||
_DOCKER_PUBLISH_ALIAS_HOSTS = frozenset(
|
||||
{"host.docker.internal", "host.containers.internal", "localhost"}
|
||||
)
|
||||
|
||||
|
||||
def docker_publish_bind_host(hls_host: str, *, explicit_bind: str = "") -> str:
|
||||
"""``docker run -p <bind>:port`` 用的宿主机地址;与 API 反代用的 ``hls_host`` 可不同。"""
|
||||
if (explicit_bind or "").strip():
|
||||
return explicit_bind.strip()
|
||||
def normalize_upstream_base(url: str) -> str:
|
||||
raw = (url or "").strip().rstrip("/")
|
||||
if not raw:
|
||||
raise ValueError("empty HLS upstream URL")
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise ValueError(f"invalid HLS upstream URL: {url!r}")
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
host = parsed.hostname
|
||||
return f"{parsed.scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def upstream_host_port(upstream_base: str) -> tuple[str, int]:
|
||||
parsed = urlparse(normalize_upstream_base(upstream_base))
|
||||
assert parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
return parsed.hostname, port
|
||||
|
||||
|
||||
def docker_publish_bind_host(hls_host: str) -> str:
|
||||
"""本机临时容器 ``docker run -p`` 的绑定地址(勿用主机名)。"""
|
||||
h = (hls_host or "").strip().lower()
|
||||
if h in _DOCKER_PUBLISH_ALIAS_HOSTS:
|
||||
return "127.0.0.1"
|
||||
return (hls_host or "").strip() or "127.0.0.1"
|
||||
|
||||
|
||||
def resolve_ephemeral_upstream_host(settings_host: str) -> str:
|
||||
"""API 在容器内且未配置 upstream 时,用 host.docker.internal 访问宿主机 published 端口。"""
|
||||
host = (settings_host or "127.0.0.1").strip() or "127.0.0.1"
|
||||
if Path("/.dockerenv").is_file() and host.lower() in ("127.0.0.1", "localhost", "::1"):
|
||||
return "host.docker.internal"
|
||||
return host
|
||||
|
||||
|
||||
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"
|
||||
@@ -62,7 +91,6 @@ def _wait_tcp(host: str, port: int, *, timeout: float) -> None:
|
||||
|
||||
|
||||
def _write_mediamtx_config(path: Path, paths: dict[str, str]) -> None:
|
||||
"""``paths``: mediamtx 路径名 -> RTSP source URL(拉流)。"""
|
||||
lines = [
|
||||
"# Generated for OR demo HLS preview",
|
||||
"pathDefaults:",
|
||||
@@ -72,20 +100,29 @@ def _write_mediamtx_config(path: Path, paths: dict[str, str]) -> None:
|
||||
for mtx_path, source in paths.items():
|
||||
lines.append(f" {mtx_path}:")
|
||||
lines.append(f" source: {source}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HlsPreviewSession:
|
||||
hls_host: str
|
||||
hls_port: int
|
||||
upstream_base: str
|
||||
path_by_camera: dict[str, str]
|
||||
mode: Literal["attached", "ephemeral"]
|
||||
container_name: str | None = None
|
||||
config_dir: Path | None = None
|
||||
|
||||
def upstream_base(self, camera_id: str) -> str:
|
||||
@property
|
||||
def hls_host(self) -> str:
|
||||
return upstream_host_port(self.upstream_base)[0]
|
||||
|
||||
@property
|
||||
def hls_port(self) -> int:
|
||||
return upstream_host_port(self.upstream_base)[1]
|
||||
|
||||
def camera_upstream_base(self, camera_id: str) -> str:
|
||||
mtx = self.path_by_camera[camera_id]
|
||||
return f"http://{self.hls_host}:{self.hls_port}/{mtx}"
|
||||
return f"{self.upstream_base.rstrip('/')}/{mtx}"
|
||||
|
||||
|
||||
class HlsPreviewManager:
|
||||
@@ -100,7 +137,7 @@ class HlsPreviewManager:
|
||||
sess = cls._active
|
||||
if sess is None:
|
||||
return
|
||||
if sess.container_name and shutil.which("docker"):
|
||||
if sess.mode == "ephemeral" and sess.container_name and shutil.which("docker"):
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", sess.container_name],
|
||||
@@ -110,41 +147,137 @@ class HlsPreviewManager:
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.debug("hls preview docker rm: {}", exc)
|
||||
if sess.config_dir and sess.config_dir.is_dir():
|
||||
if sess.config_dir and sess.config_dir.is_dir() and sess.mode == "ephemeral":
|
||||
shutil.rmtree(sess.config_dir, ignore_errors=True)
|
||||
cls._active = None
|
||||
|
||||
@classmethod
|
||||
def start_pull(
|
||||
def start(
|
||||
cls,
|
||||
*,
|
||||
sources: dict[str, str],
|
||||
hls_host: str,
|
||||
hls_port: int,
|
||||
bind_host: str | None = None,
|
||||
upstream_url: str = "",
|
||||
config_dir: str = "",
|
||||
attached_container_name: str = "orm-mediamtx-hls",
|
||||
ephemeral_access_host: str = "127.0.0.1",
|
||||
ephemeral_port: int = 18888,
|
||||
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()
|
||||
if (upstream_url or "").strip():
|
||||
return cls._start_attached(
|
||||
sources=sources,
|
||||
upstream_url=upstream_url.strip(),
|
||||
config_dir=config_dir,
|
||||
container_name=attached_container_name,
|
||||
ready_timeout_sec=ready_timeout_sec,
|
||||
)
|
||||
return cls._start_ephemeral(
|
||||
sources=sources,
|
||||
access_host=ephemeral_access_host,
|
||||
port=ephemeral_port,
|
||||
ready_timeout_sec=ready_timeout_sec,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_path_maps(
|
||||
cls, sources: dict[str, str]
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
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
|
||||
return path_by_camera, mtx_paths
|
||||
|
||||
@classmethod
|
||||
def _start_attached(
|
||||
cls,
|
||||
*,
|
||||
sources: dict[str, str],
|
||||
upstream_url: str,
|
||||
config_dir: str,
|
||||
container_name: str,
|
||||
ready_timeout_sec: float,
|
||||
) -> HlsPreviewSession:
|
||||
if not sources:
|
||||
raise ValueError("no camera sources for HLS preview")
|
||||
if not shutil.which("docker"):
|
||||
raise RuntimeError(
|
||||
"docker not in PATH (required to reload mediamtx-hls; mount docker.sock into api)"
|
||||
)
|
||||
|
||||
cls.stop()
|
||||
upstream_base = normalize_upstream_base(upstream_url)
|
||||
path_by_camera, mtx_paths = cls._build_path_maps(sources)
|
||||
|
||||
cfg_root = Path((config_dir or "/hls-preview-config").strip())
|
||||
cfg_file = cfg_root / "mediamtx.yml"
|
||||
_write_mediamtx_config(cfg_file, mtx_paths)
|
||||
|
||||
logger.info(
|
||||
"HLS preview (attached) writing {} and restarting {}",
|
||||
cfg_file,
|
||||
container_name,
|
||||
)
|
||||
r = subprocess.run(
|
||||
["docker", "restart", container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "").strip()
|
||||
raise RuntimeError(
|
||||
f"failed to restart {container_name} (is mediamtx-hls running in compose?): {err}"
|
||||
)
|
||||
|
||||
host, port = upstream_host_port(upstream_base)
|
||||
try:
|
||||
_wait_tcp(host, port, timeout=ready_timeout_sec)
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"HLS preview not reachable at {upstream_base} after restarting {container_name}"
|
||||
) from None
|
||||
|
||||
sess = HlsPreviewSession(
|
||||
upstream_base=upstream_base,
|
||||
path_by_camera=path_by_camera,
|
||||
mode="attached",
|
||||
container_name=container_name,
|
||||
config_dir=cfg_root,
|
||||
)
|
||||
cls._active = sess
|
||||
logger.info(
|
||||
"HLS preview (attached) ready upstream={} cameras={}",
|
||||
upstream_base,
|
||||
list(path_by_camera),
|
||||
)
|
||||
return sess
|
||||
|
||||
@classmethod
|
||||
def _start_ephemeral(
|
||||
cls,
|
||||
*,
|
||||
sources: dict[str, str],
|
||||
access_host: str,
|
||||
port: int,
|
||||
ready_timeout_sec: float,
|
||||
) -> HlsPreviewSession:
|
||||
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, mtx_paths = cls._build_path_maps(sources)
|
||||
|
||||
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)
|
||||
|
||||
publish_host = docker_publish_bind_host(hls_host, explicit_bind=bind_host or "")
|
||||
publish_host = docker_publish_bind_host("127.0.0.1")
|
||||
container = CONTAINER_NAME_PREFIX + uuid.uuid4().hex[:12]
|
||||
cmd = [
|
||||
"docker",
|
||||
@@ -155,38 +288,38 @@ class HlsPreviewManager:
|
||||
"-v",
|
||||
f"{cfg.resolve()}:/mediamtx.yml:ro",
|
||||
"-p",
|
||||
f"{publish_host}:{hls_port}:{_MEDIAMTX_HLS_INTERNAL_PORT}",
|
||||
f"{publish_host}:{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}")
|
||||
raise RuntimeError(f"HLS preview MediaMTX failed (publish {publish_host}:{port}): {err}")
|
||||
|
||||
access = resolve_ephemeral_upstream_host(access_host)
|
||||
upstream_base = normalize_upstream_base(f"http://{access}:{port}")
|
||||
host, _ = upstream_host_port(upstream_base)
|
||||
try:
|
||||
_wait_tcp(hls_host, hls_port, timeout=ready_timeout_sec)
|
||||
_wait_tcp(host, 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,
|
||||
upstream_base=upstream_base,
|
||||
path_by_camera=path_by_camera,
|
||||
mode="ephemeral",
|
||||
container_name=container,
|
||||
config_dir=work,
|
||||
)
|
||||
cls._active = sess
|
||||
logger.info(
|
||||
"HLS preview (pull) started container={} cameras={} publish={}:{} upstream=http://{}:{}/",
|
||||
"HLS preview (ephemeral) container={} upstream={} cameras={}",
|
||||
container,
|
||||
upstream_base,
|
||||
list(path_by_camera),
|
||||
publish_host,
|
||||
hls_port,
|
||||
hls_host,
|
||||
hls_port,
|
||||
)
|
||||
return sess
|
||||
|
||||
@@ -195,13 +328,12 @@ class HlsPreviewManager:
|
||||
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("/")
|
||||
base = sess.camera_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()
|
||||
|
||||
@@ -21,6 +21,23 @@ services:
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
|
||||
mediamtx-hls:
|
||||
image: ${MEDIAMTX_DOCKER_IMAGE:-m.daocloud.io/docker.io/bluenviron/mediamtx:latest}
|
||||
container_name: orm-mediamtx-hls
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
if [ ! -f /config/mediamtx.yml ]; then
|
||||
cp /bootstrap/mediamtx.yml /config/mediamtx.yml
|
||||
fi
|
||||
exec /mediamtx /config/mediamtx.yml
|
||||
ports:
|
||||
- "127.0.0.1:${DEMO_HLS_PREVIEW_PORT:-18888}:8888"
|
||||
volumes:
|
||||
- hls_preview_config:/config
|
||||
- ./resources/mediamtx_hls_preview.bootstrap.yml:/bootstrap/mediamtx.yml:ro
|
||||
|
||||
minio:
|
||||
image: m.daocloud.io/docker.io/minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
@@ -88,19 +105,26 @@ 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}
|
||||
# api 使用 network:host,经宿主机端口访问 mediamtx-hls(勿用 mediamtx-hls 主机名)
|
||||
DEMO_HLS_PREVIEW_UPSTREAM: ${DEMO_HLS_PREVIEW_UPSTREAM:-http://127.0.0.1:18888}
|
||||
DEMO_HLS_PREVIEW_CONFIG_DIR: ${DEMO_HLS_PREVIEW_CONFIG_DIR:-/hls-preview-config}
|
||||
DEMO_HLS_PREVIEW_CONTAINER_NAME: ${DEMO_HLS_PREVIEW_CONTAINER_NAME:-orm-mediamtx-hls}
|
||||
MEDIAMTX_DOCKER_IMAGE: ${MEDIAMTX_DOCKER_IMAGE:-m.daocloud.io/docker.io/bluenviron/mediamtx:latest}
|
||||
command: >
|
||||
sh -c "uv run --no-sync alembic upgrade head &&
|
||||
uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 8000"
|
||||
ports:
|
||||
- "${API_PORT:-38080}:8000"
|
||||
volumes:
|
||||
- hls_preview_config:/hls-preview-config
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
mediamtx-hls:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
@@ -118,3 +142,4 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
minio_data:
|
||||
hls_preview_config:
|
||||
|
||||
4
backend/resources/mediamtx_hls_preview.bootstrap.yml
Normal file
4
backend/resources/mediamtx_hls_preview.bootstrap.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Demo HLS 预览:启动占位;ensure 会覆盖同卷中的 mediamtx.yml
|
||||
pathDefaults:
|
||||
sourceOnDemand: yes
|
||||
paths: {}
|
||||
@@ -14,6 +14,8 @@ from app.services.hls_preview import (
|
||||
HlsPreviewManager,
|
||||
HlsPreviewSession,
|
||||
docker_publish_bind_host,
|
||||
normalize_upstream_base,
|
||||
resolve_ephemeral_upstream_host,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,24 +34,28 @@ def hls_client(tmp_path) -> TestClient:
|
||||
HlsPreviewManager.stop()
|
||||
|
||||
|
||||
def test_normalize_upstream_base() -> None:
|
||||
assert normalize_upstream_base("http://mediamtx-hls:8888") == "http://mediamtx-hls:8888"
|
||||
assert normalize_upstream_base("http://mediamtx-hls:8888/") == "http://mediamtx-hls:8888"
|
||||
|
||||
|
||||
def test_docker_publish_bind_host_maps_docker_internal() -> None:
|
||||
assert docker_publish_bind_host("host.docker.internal") == "127.0.0.1"
|
||||
assert docker_publish_bind_host("127.0.0.1") == "127.0.0.1"
|
||||
assert docker_publish_bind_host("host.docker.internal", explicit_bind="0.0.0.0") == "0.0.0.0"
|
||||
|
||||
|
||||
def test_hls_preview_ensure_pull(hls_client: TestClient) -> None:
|
||||
fake_sess = HlsPreviewSession(
|
||||
hls_host="127.0.0.1",
|
||||
hls_port=18888,
|
||||
upstream_base="http://127.0.0.1:18888",
|
||||
path_by_camera={"or-cam-01": "or-cam-01"},
|
||||
mode="ephemeral",
|
||||
)
|
||||
|
||||
def _fake_start(**_kwargs: object) -> HlsPreviewSession:
|
||||
HlsPreviewManager._active = fake_sess
|
||||
return fake_sess
|
||||
|
||||
with patch.object(HlsPreviewManager, "start_pull", side_effect=_fake_start):
|
||||
with patch.object(HlsPreviewManager, "start", side_effect=_fake_start):
|
||||
r = hls_client.post(
|
||||
"/internal/demo/hls-preview/ensure",
|
||||
json={"camera_ids": ["or-cam-01"]},
|
||||
@@ -62,9 +68,9 @@ def test_hls_preview_ensure_pull(hls_client: TestClient) -> None:
|
||||
|
||||
def test_hls_proxy_rewrites_playlist(hls_client: TestClient) -> None:
|
||||
HlsPreviewManager._active = HlsPreviewSession(
|
||||
hls_host="127.0.0.1",
|
||||
hls_port=18888,
|
||||
upstream_base="http://127.0.0.1:18888",
|
||||
path_by_camera={"or-cam-01": "or-cam-01"},
|
||||
mode="attached",
|
||||
)
|
||||
playlist = (
|
||||
"#EXTM3U\n#EXT-X-TARGETDURATION:1\n"
|
||||
@@ -77,3 +83,10 @@ def test_hls_proxy_rewrites_playlist(hls_client: TestClient) -> None:
|
||||
text = r.text
|
||||
assert "/internal/demo/hls-preview/or-cam-01/segment.ts" in text
|
||||
assert "127.0.0.1:18888" not in text
|
||||
|
||||
|
||||
def test_resolve_ephemeral_upstream_host_local() -> None:
|
||||
with patch("app.services.hls_preview.Path") as path_cls:
|
||||
path_cls.return_value.is_file.return_value = False
|
||||
assert resolve_ephemeral_upstream_host("127.0.0.1") == "127.0.0.1"
|
||||
|
||||
|
||||
@@ -61,16 +61,20 @@ SDK **不作为构建期依赖**:将厂商提供的 Linux x86_64 动态库挂
|
||||
|
||||
浏览器无法直接播放 RTSP。联调台通过 **MediaMTX** 将 RTSP 转为 **HLS**,由 FastAPI 反代 m3u8/ts 后由 **hls.js** 播放。
|
||||
|
||||
| 部署方式 | 行为 |
|
||||
|----------|------|
|
||||
| **Docker Compose(推荐)** | 固定服务 `mediamtx-hls`(宿主机 `127.0.0.1:18888`);`ensure` 写共享 `mediamtx.yml` 并 `docker restart orm-mediamtx-hls`;`DEMO_HLS_PREVIEW_UPSTREAM=http://127.0.0.1:18888` |
|
||||
| **本机 uvicorn** | 未设 upstream 时按需 `docker run` 临时 MediaMTX(端口默认 `127.0.0.1:18888`) |
|
||||
|
||||
| 链路 | 行为 |
|
||||
|------|------|
|
||||
| **链路 1**(真 RTSP) | `POST /internal/demo/hls-preview/ensure` 在宿主机起独立 MediaMTX 容器,按 `or_site_config` 的 `video_rtsp_urls` 拉流 |
|
||||
| **链路 2**(模拟实时) | 不使用 HLS/RTSP 预览;联调台在各路槽位用本地 `<video>` 预览所选文件 |
|
||||
| **链路 1**(真 RTSP) | `POST /internal/demo/hls-preview/ensure` 按 `or_site_config` 的 `video_rtsp_urls` 配置 MediaMTX 路径 |
|
||||
| **链路 2**(模拟实时) | 不使用 HLS;联调台在各路槽位用本地 `<video>` 预览所选文件 |
|
||||
|
||||
- 播放地址形如:`GET /internal/demo/hls-preview/{camera_id}/index.m3u8`(playlist 内分片 URL 会重写为 API 路径)。
|
||||
- **API 在 Docker 内**时设 `DEMO_HLS_PREVIEW_HOST=host.docker.internal`(compose 已默认):API 经该主机名访问宿主机上的 HLS;`docker run -p` 会自动绑定 `127.0.0.1:18888`(不能写 `host.docker.internal` 作 `-p` 地址)。
|
||||
- 环境变量:`DEMO_HLS_PREVIEW_PORT`(默认 18888)、`DEMO_HLS_PREVIEW_HOST`(反代上游)、可选 `DEMO_HLS_PREVIEW_BIND_HOST`(显式 `-p` 绑定,一般留空)。
|
||||
- 播放地址:`GET /internal/demo/hls-preview/{camera_id}/index.m3u8`(playlist 内 URL 重写为 API 路径)。
|
||||
- Compose 需将 **docker.sock** 挂入 `api`(用于 reload mediamtx-hls),与模拟假流 orchestrator 相同。
|
||||
|
||||
单帧 JPEG 预览仍保留:`GET /internal/demo/rtsp-preview/{camera_id}/frame.jpg`(不依赖 MediaMTX)。
|
||||
单帧 JPEG 预览仍保留:`GET /internal/demo/rtsp-preview/{camera_id}/frame.jpg`。
|
||||
|
||||
## 相关环境变量
|
||||
|
||||
|
||||
Reference in New Issue
Block a user