This commit is contained in:
Kevin
2026-05-22 16:04:32 +08:00
parent 4736413f94
commit 831000101e
8 changed files with 262 additions and 81 deletions

View File

@@ -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-hlsAPI 反代)
# 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 内访问宿主机 HLShost.docker.internaldocker -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 容器用

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# Demo HLS 预览启动占位ensure 会覆盖同卷中的 mediamtx.yml
pathDefaults:
sourceOnDemand: yes
paths: {}

View File

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

View File

@@ -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`
## 相关环境变量