Replace chain 1 with RTSP slice batch pipeline and add 24h segment TTL.

Route live recording through ffmpeg MP4 segments and the 5.15 batch subprocess, remove simulated RTSP chain 2, purge expired slices on startup and hourly, and expose TTL settings to the demo client.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-25 10:24:25 +08:00
parent 0917109d6a
commit 30e6acea70
36 changed files with 1492 additions and 1674 deletions

View File

@@ -4,7 +4,7 @@
| 目录 | 用途 | 默认端口 |
|------|------|----------|
| [demo-client/](demo-client/) | 三链路联调(真 RTSP / 模拟实时 / 离线 batch | 38081 |
| [voice-confirmation/](voice-confirmation/) | 语音确认终端页(链路 1/2 开录后使用) | 8080 |
| [demo-client/](demo-client/) | 联调(真 RTSP 切片 batch / 离线 batch;链路 1 落盘 slice 默认 24h TTL | 38081 |
| [voice-confirmation/](voice-confirmation/) | 语音确认终端页(链路 1 开录后使用) | 8080 |
启动前请先在 `backend/` 执行 `docker compose up -d --build`

View File

@@ -1,55 +1,32 @@
# Demo Client · 联调台
浏览器联调页,覆盖条录制链路。语音待确认请使用 [`../voice-confirmation/`](../voice-confirmation/)(默认 :8080
浏览器联调页,覆盖条录制链路。语音待确认请使用 [`../voice-confirmation/`](../voice-confirmation/)(默认 :8080
## 条链路
## 条链路
| 模式 | 操作 | API | 语音 | 结束手术 |
|------|------|-----|------|----------|
| **链路 1 · 真摄像头** | 填 camera_id → 开始手术 | `POST /client/surgeries/start` | 需要 | 需要 |
| **链路 2 · 模拟实时** | 选满 N 路视频 → 开始模拟开录 | `POST /internal/demo/simulated-start` | 需要 | 需要 |
| **链路 3 · 离线精确** | 选 MP4 → 上传并处理 | `POST /internal/demo/offline-batch` | 无 | 不需要 |
链路 2/3 需 `DEMO_ORCHESTRATOR_ENABLED=true`;链路 2 还需可写 `OR_SITE_CONFIG_JSON_FILE`。页顶「刷新状态」可查看 API 与 Demo 模式是否就绪。
链路 3 需 `DEMO_ORCHESTRATOR_ENABLED=true`。页顶「刷新状态」可查看 API 与 Demo 模式是否就绪。
## 界面说明
- **模式卡片**:点选切换,只显示当前模式相关配置
- **耗材**:标签 chip 多选;「高级」可编辑 JSON
- **链路 2**默认只需上传视频RTSP 路径与 camera_id 在「高级」折叠
- **链路 3**:独立 MP4 上传区,可选生成标注视频
- **开发者日志**:右侧折叠,记录完整 HTTP 请求/响应
- **链路 1**:填写 `camera_ids`(逗号分隔);默认仅 `or-cam-03` 参与 RTSP 录像切片与 batch 算法。服务端落盘 `slice_*.mp4` 仅用于推理,默认 **24 小时**后自动删除(`RTSP_SEGMENT_TTL_HOURS`),不影响已入库消耗明细;页顶「刷新状态」会显示当前切片间隔与保留时长。
- **链路 3**:独立 MP4 上传区,可选生成标注视频(独立 TTL`VIDEO_BATCH_VIS_TTL_HOURS`,默认 24 小时)
## 运行
详见 [`docs/video-backends.md`](../../docs/video-backends.md)。
## 启动
```bash
cd backend && docker compose up -d --build
cd ../clients/demo-client && ./start.sh
open http://127.0.0.1:38081/
cd clients/demo-client
python3 -m http.server 38081
```
API 地址默认 `http://127.0.0.1:38080`;从局域网访问 demo 页时会自动改用当前主机 IP
浏览器打开 `http://127.0.0.1:38081/`API 地址填后端(默认 `http://127.0.0.1:38080`
## 文件
## HLS 预览(链路 1
```
clients/demo-client/
index.html # 页面骨架
styles.css # 样式
app.js # 逻辑
server.py # 静态服务 + GET /labels.json
labels.yaml # 耗材标签(与后端同步)
start.sh
```
## 手跑假 RTSP链路 2 高级)
```bash
python3 fake_rtsp_from_file.py /path/to/video.mp4 --port 18554 --path demo
```
详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
## CORS
跨域访问 API 时设置 `DEMO_CORS_ENABLED=true`
真 RTSP 可通过 MediaMTX 转 HLS 在页内预览;点击「启动 / 刷新预览」。

View File

@@ -5,8 +5,6 @@
"use strict";
const $ = (id) => document.getElementById(id);
const DEF_CAMS = ["or-cam-03", "or-cam-02", "or-cam-04", "or-cam-01"];
const DEF_RP = ["demo1", "demo2", "demo3", "demo4"];
let activeMode = "live-rtsp";
let allLabels = [];
@@ -14,8 +12,7 @@
let lastVideoBatchDoctorDisplay = "";
let videoVisToken = 0;
const hlsPlayers = {};
const slotBlobUrls = {};
const webcamSlotState = {};
let serverRecordingConfig = null;
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
const surgeryId = () => $("surgery-id").value.trim();
@@ -101,6 +98,71 @@
return m + " 分 " + s + " 秒";
}
function formatHoursLabel(hours) {
const n = Number(hours);
if (!Number.isFinite(n) || n <= 0) return "—";
return Number.isInteger(n) ? `${n} 小时` : `${n} 小时`;
}
function applyRecordingConfigHints(data) {
if (!data || typeof data !== "object") return;
serverRecordingConfig = data;
const primary = String(data.rtsp_primary_camera_id || "or-cam-03").trim() || "or-cam-03";
const duration = formatDurationSec(data.rtsp_segment_duration_sec ?? 120);
const sliceTtl = formatHoursLabel(data.rtsp_segment_ttl_hours ?? 24);
const visTtl = formatHoursLabel(data.video_batch_vis_ttl_hours ?? 24);
const recordAll = data.rtsp_record_all_cameras === true;
const liveHint = $("live-rtsp-segment-hint");
if (liveHint) {
const cameraLine = recordAll
? "请求中所有可解析 RTSP 的机位均参与录像"
: `默认仅录制 ${primary} 机位`;
liveHint.textContent =
`${cameraLine},每 ${duration} 切片跑 batch` +
`服务端落盘 slice 仅用于推理,${sliceTtl} 后自动删除(不影响已入库明细)。` +
"下方可预览每路画面。";
}
const visLabel = $("offline-batch-vis-label");
if (visLabel) {
visLabel.textContent = `生成标注视频(${visTtl} 内可预览)`;
}
}
function updateDemoModesPill(data, resOk) {
const pill = $("pill-demo-modes");
if (!pill) return;
if (activeMode === "live-rtsp") {
if (!resOk || !data) {
pill.textContent = "链路 1 配置未拉取";
pill.className = "pill err";
return;
}
const duration = formatDurationSec(data.rtsp_segment_duration_sec ?? 120);
const sliceTtl = formatHoursLabel(data.rtsp_segment_ttl_hours ?? 24);
pill.textContent = `切片 ${duration} · 保留 ${sliceTtl}`;
pill.className = "pill ok";
return;
}
if (!resOk || !data) {
pill.textContent = "状态拉取失败";
pill.className = "pill err";
return;
}
const on = data.demo_recording_modes_enabled === true || data.orchestrator_enabled === true;
if (on) {
pill.textContent = "链路 3 已就绪";
pill.className = "pill ok";
} else {
pill.textContent = "DEMO 未开启";
pill.className = "pill err";
}
}
function showOfflineBatchTiming(textSec, videoSec, totalSec, videoStatus) {
const el = $("offline-batch-timing");
if (!el) return;
@@ -362,20 +424,11 @@
const btnStart = $("btn-start");
const btnEnd = $("btn-end");
if (btnStart) {
btnStart.textContent =
mode === "offline-batch"
? "上传并处理"
: mode === "live-simulated"
? "开始模拟开录"
: "开始手术";
btnStart.textContent = mode === "offline-batch" ? "上传并处理" : "开始手术";
}
if (btnEnd) btnEnd.disabled = mode === "offline-batch";
$("pill-mode").textContent =
mode === "live-rtsp"
? "链路 1 · 真摄像头"
: mode === "live-simulated"
? "链路 2 · 模拟实时"
: "链路 3 · 离线精确";
mode === "live-rtsp" ? "链路 1 · 真摄像头" : "链路 3 · 离线精确";
if (mode !== "offline-batch") hideVideoBatchVisualization();
refreshRecordingModesStatus();
if (mode === "live-rtsp") {
@@ -402,35 +455,6 @@
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 setPreviewStatus(text, state) {
const status = $("preview-status");
if (!status) return;
@@ -620,39 +644,19 @@
}
async function refreshRecordingModesStatus() {
const pill = $("pill-demo-modes");
if (activeMode === "live-rtsp") {
if (pill) {
pill.textContent = "链路 2/3 未检测";
pill.className = "pill";
}
return;
}
const url = baseUrl() + "/internal/demo/recording-modes-status";
try {
const res = await fetch(url);
const data = await res.json();
addLog("GET", url, res.status, data, { error: !res.ok });
if (!pill) return;
const on = data.demo_recording_modes_enabled === true || data.orchestrator_enabled === true;
const fset = data.or_site_config_json_file_set === true;
if (!res.ok) {
pill.textContent = "状态拉取失败";
pill.className = "pill err";
return;
}
if (on && fset) {
pill.textContent = "链路 2/3 已就绪";
pill.className = "pill ok";
} else if (on) {
pill.textContent = "缺 OR_SITE_CONFIG";
pill.className = "pill warn";
} else {
pill.textContent = "DEMO 未开启";
pill.className = "pill err";
if (res.ok) {
applyRecordingConfigHints(data);
}
updateDemoModesPill(data, res.ok);
} catch (e) {
if (pill) {
updateDemoModesPill(null, false);
const pill = $("pill-demo-modes");
if (pill && activeMode !== "live-rtsp") {
pill.textContent = "状态失败";
pill.className = "pill err";
}
@@ -794,36 +798,12 @@
void waitForVideoBatchVisualization(sid, urlPath, doctorDisplay);
}
function getDebugStreamCount() {
const sel = $("debug-stream-count");
const v = sel ? parseInt(sel.value, 10) : 1;
return v >= 1 && v <= 4 ? v : 1;
}
function applyDebugStreamVisibility() {
const n = getDebugStreamCount();
for (let i = 1; i <= 4; i++) {
const el = $("sim-stream-" + i);
if (el) el.classList.toggle("hidden", i > n);
}
}
function assignFileToInput(inputEl, file) {
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
}
function setSlotFile(slot, file, source) {
const vfile = $("sim-vfile-" + slot);
const hint = $("sim-hint-" + slot);
const fname = $("sim-fname-" + slot);
if (vfile && file) assignFileToInput(vfile, file);
if (fname) fname.textContent = file ? file.name : "点击或拖放视频";
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
updateSlotFilePreview(slot, file || null);
}
function setOfflineFile(file, source) {
const vfile = $("offline-vfile");
const fname = $("offline-fname");
@@ -856,78 +836,6 @@
);
}
function pickMediaRecorderMime() {
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) return "";
for (const m of [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4",
]) {
if (MediaRecorder.isTypeSupported(m)) return m;
}
return "";
}
async function toggleWebcamSlot(slot) {
const st = webcamSlotState[slot] || (webcamSlotState[slot] = {});
const btn = $("sim-webcam-" + slot);
const hint = $("sim-hint-" + slot);
if (!st.recording) {
if (!navigator.mediaDevices?.getUserMedia) {
showBanner("需要 HTTPS 或 localhost 才能使用摄像头", "warn");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user" },
audio: true,
});
const mime = pickMediaRecorderMime();
st.chunks = [];
const rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
rec.ondataavailable = (ev) => {
if (ev.data?.size) st.chunks.push(ev.data);
};
rec.start(250);
st.recording = true;
st.stream = stream;
st.recorder = rec;
if (btn) {
btn.textContent = "停止录制";
btn.classList.add("warn");
}
if (hint) hint.textContent = "录制中…";
} catch (e) {
showBanner("无法打开摄像头:" + (e.message || String(e)), "err");
}
return;
}
const rec = st.recorder;
const stream = st.stream;
st.recording = false;
st.recorder = null;
st.stream = null;
if (btn) {
btn.textContent = "摄像头";
btn.classList.remove("warn");
}
await new Promise((resolve) => {
rec.addEventListener("stop", resolve, { once: true });
rec.stop();
});
stream?.getTracks().forEach((t) => t.stop());
const blob = new Blob(st.chunks, { type: rec.mimeType || "video/webm" });
st.chunks = [];
if (!blob.size) {
if (hint) hint.textContent = "未录到数据";
return;
}
const ext = (rec.mimeType || "").includes("mp4") ? ".mp4" : ".webm";
const file = new File([blob], "webcam-" + slot + "-" + Date.now() + ext, { type: blob.type });
setSlotFile(slot, file, "摄像头");
}
async function handleStart() {
const sid = ensureSurgeryId();
if (!sid) return;
@@ -984,50 +892,6 @@
return;
}
if (mode === "live-simulated") {
const n = getDebugStreamCount();
const files = [];
for (let i = 1; i <= n; i++) {
files.push($("sim-vfile-" + i)?.files?.[0]);
}
if (files.length !== n || !files.every(Boolean)) {
showBanner("请为路 1…" + n + " 全部选择视频文件", "err");
return;
}
const fd = new FormData();
fd.append("surgery_id", sid);
fd.append("video1", files[0], files[0].name);
if (n >= 2) fd.append("video2", files[1], files[1].name);
if (n >= 3) fd.append("video3", files[2], files[2].name);
if (n >= 4) fd.append("video4", files[3], files[3].name);
for (let i = 1; i <= 4; i++) {
fd.append(
"camera_" + i,
($("adv-cam-" + i)?.value || DEF_CAMS[i - 1]).trim() || DEF_CAMS[i - 1],
);
fd.append(
"rtsp_path_" + i,
($("adv-rpath-" + i)?.value || DEF_RP[i - 1]).trim() || DEF_RP[i - 1],
);
}
fd.append("candidate_consumables_json", JSON.stringify(candidateConsumables));
const { res, body } = await apiMultipart(
"/internal/demo/simulated-start",
fd,
"simulated-start",
);
if (!res.ok) {
const dup = body?.detail?.code === "SURGERY_ALREADY_RECORDING";
showBanner(
dup ? "请勿重复开始:" + formatResultUnavailable(body) : "模拟开录失败:" + formatDetail(body),
"err",
);
return;
}
showBanner("模拟开录已接受,请打开语音终端", "ok");
return;
}
const camera_ids = $("camera-ids")
.value.split(",")
.map((s) => s.trim())
@@ -1136,29 +1000,6 @@
setActiveMode("live-rtsp");
}
function initSimulatedUploads() {
$("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility);
applyDebugStreamVisibility();
for (let i = 1; i <= 4; i++) {
const pick = $("sim-pick-" + i);
const vfile = $("sim-vfile-" + i);
const zone = $("sim-zone-" + i);
if (pick && vfile) {
pick.onclick = () => vfile.click();
vfile.onchange = () => {
const f = vfile.files?.[0];
if (f) setSlotFile(i, f, "选择");
};
}
bindDropZone(zone, (f) => setSlotFile(i, f, "拖放"));
zone?.addEventListener("click", (ev) => {
if (ev.target.closest("button")) return;
vfile?.click();
});
$("sim-webcam-" + i)?.addEventListener("click", () => toggleWebcamSlot(i));
}
}
function initOfflineUpload() {
const zone = $("offline-zone");
const vfile = $("offline-vfile");
@@ -1193,7 +1034,6 @@
function init() {
applyLanDefaultApiBase();
initModeCards();
initSimulatedUploads();
initOfflineUpload();
initConsumables();
loadLabels();
@@ -1212,13 +1052,13 @@
$("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();
refreshRecordingModesStatus();
}
if (document.readyState === "loading") {

View File

@@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""Publish local video file(s) to RTSP once per file (fake camera) for local dev.
The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API.
This script does NOT change the application backend: it runs ffmpeg + a small
RTSP server (MediaMTX); put the printed ``video_rtsp_urls`` into ``OR_SITE_CONFIG_JSON_FILE``.
Requires:
- ffmpeg in PATH
- Docker; 默认拉取 ``MEDIAMTX_DOCKER_IMAGE``DaoCloud 前缀的 bluenviron/mediamtx
或本地已拉取的镜像;也可用 PATH 中的 ``mediamtx`` 二进制(高级)。
Single stream (legacy)::
python3 fake_rtsp_from_file.py /path/to/video.mp4
python3 fake_rtsp_from_file.py video.mp4 --port 18554 --path demo
Multiple streams (one MediaMTX, one ffmpeg per camera; different RTSP path per stream)::
python3 fake_rtsp_from_file.py --port 18554 \\
--stream 'or-cam-01|./a.mp4|demo1' \\
--stream 'or-cam-02|./b.mp4|demo2'
--stream format: ``CAMERA_ID|FILE|RTSP_PATH`` (use quotes in shell; RTSP path is
the last segment, e.g. ``demo1`` -> ``rtsp://127.0.0.1:<port>/demo1``).
"""
from __future__ import annotations
import argparse
import atexit
import json
import os
import signal
import shutil
import subprocess
import sys
import time
from pathlib import Path
# 默认 DaoCloud 镜像前缀;可设 MEDIAMTX_DOCKER_IMAGE=bluenviron/mediamtx:latest 直连 Docker Hub
MEDIAMTX_IMAGE = os.environ.get(
"MEDIAMTX_DOCKER_IMAGE",
"m.daocloud.io/docker.io/bluenviron/mediamtx:latest",
)
CONTAINER_NAME = "orm-fake-rtsp-mediamtx"
def _has_docker() -> bool:
return shutil.which("docker") is not None
def _has_ffmpeg() -> bool:
return shutil.which("ffmpeg") is not None
def _stop_mediamtx_container() -> None:
if not _has_docker():
return
try:
subprocess.run(
["docker", "rm", "-f", CONTAINER_NAME],
capture_output=True,
check=False,
timeout=30,
)
except (OSError, subprocess.SubprocessError):
pass
def _start_mediamtx_docker(host_port: int) -> bool:
_stop_mediamtx_container()
cmd = [
"docker",
"run",
"-d",
"--name",
CONTAINER_NAME,
"-p",
f"127.0.0.1:{host_port}:8554",
MEDIAMTX_IMAGE,
]
print("[fake-rtsp] Starting MediaMTX:", " ".join(cmd), file=sys.stderr)
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except (OSError, subprocess.SubprocessError) as exc:
print(f"[fake-rtsp] docker run failed: {exc}", file=sys.stderr)
return False
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
print(f"[fake-rtsp] docker run exit {proc.returncode}: {err}", file=sys.stderr)
return False
atexit.register(_stop_mediamtx_container)
return True
def _parse_stream_arg(spec: str) -> tuple[str, Path, str]:
parts = spec.split("|", 2)
if len(parts) != 3:
raise ValueError(f"Invalid --stream {spec!r}; expected CAM|FILE|RTSP_PATH (three fields separated by |)")
cam = parts[0].strip()
fpath = Path(parts[1].strip()).expanduser()
rpath = parts[2].strip().strip("/")
if not cam:
raise ValueError("empty camera id in --stream")
if not rpath:
rpath = "demo"
return cam, fpath, rpath
def main() -> int:
parser = argparse.ArgumentParser(
description="Play each video file once to an RTSP URL (dev fake camera; no backend code change).",
)
parser.add_argument(
"video",
nargs="?",
type=Path,
default=None,
help="(single-stream mode) Path to a video file",
)
parser.add_argument(
"--path",
default="demo",
help="(single-stream mode) RTSP path segment (rtsp://host:port/<path>)",
)
parser.add_argument(
"--port",
type=int,
default=18554,
help="Host port mapped to MediaMTX RTSP (container internal 8554). Default: 18554",
)
parser.add_argument(
"--stream",
action="append",
default=None,
help=("Multi-stream mode. Repeat for each camera. Format: CAM|FILE|RTSP_PATH e.g. or-cam-01|./a.mp4|demo1"),
)
parser.add_argument(
"--no-docker",
action="store_true",
help="Do not start Docker; run MediaMTX yourself on the host port mapping.",
)
args = parser.parse_args()
if not _has_ffmpeg():
print("ffmpeg not found in PATH. Install ffmpeg and retry.", file=sys.stderr)
return 1
streams: list[tuple[str, Path, str]] = []
if args.stream:
for s in args.stream:
try:
streams.append(_parse_stream_arg(s))
except ValueError as exc:
print(f"[fake-rtsp] {exc}", file=sys.stderr)
return 1
elif args.video is not None:
fpath = args.video.resolve()
sp = (args.path or "demo").strip().strip("/") or "demo"
streams = [("or-cam-01", fpath, sp)]
else:
parser.error("Provide a video file (single mode) or one or more --stream CAM|FILE|RTSP_PATH")
for cam, fpath, rpath in streams:
rp_file = fpath.resolve()
if not rp_file.is_file():
print(f"File not found: {rp_file} (camera {cam!r})", file=sys.stderr)
return 1
for ch in rpath:
if ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-":
print(
f"[fake-rtsp] RTSP path segment {rpath!r} for {cam!r} should be "
r"[a-zA-Z0-9_.-] only; adjust --path/--stream",
file=sys.stderr,
)
return 1
host_port: int = args.port
if not args.no_docker:
if not _has_docker():
print("Docker not found. Use --no-docker and start MediaMTX manually.", file=sys.stderr)
return 1
if not _start_mediamtx_docker(host_port):
return 1
print("[fake-rtsp] MediaMTX container started. Waiting for RTSP…", file=sys.stderr)
time.sleep(1.0)
else:
print(
f"[fake-rtsp] --no-docker: ensure an RTSP server is listening for publish on port {host_port}.",
file=sys.stderr,
)
procs: list[subprocess.Popen] = []
url_map: dict[str, str] = {}
for cam, fpath, stream_path in streams:
fp = fpath.resolve()
dest_url = f"rtsp://127.0.0.1:{host_port}/{stream_path}"
url_map[cam] = dest_url
publish_cmd: list[str] = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"info",
"-re",
"-i",
str(fp),
"-c",
"copy",
"-f",
"rtsp",
"-rtsp_transport",
"tcp",
dest_url,
]
print("---", file=sys.stderr)
print(f"Publish {cam} -> {dest_url}", file=sys.stderr)
print(" " + " ".join(publish_cmd), file=sys.stderr)
p = subprocess.Popen(publish_cmd) # noqa: S603
procs.append(p)
site_doc = {"video_rtsp_urls": url_map, "voice_or_room_bindings": []}
print("---", file=sys.stderr)
print("RTSP mapping (per camera):", file=sys.stderr)
for k, u in url_map.items():
print(f" {k}: {u}", file=sys.stderr)
print("", file=sys.stderr)
print(
"OR site config (merge video_rtsp_urls into OR_SITE_CONFIG_JSON_FILE; add voice_or_room_bindings as needed):",
file=sys.stderr,
)
print(json.dumps(site_doc, ensure_ascii=False, indent=2), file=sys.stderr)
print("", file=sys.stderr)
print("If the server runs in Docker on Mac/Win, use host.docker.internal, e.g.:", file=sys.stderr)
for cam, u in url_map.items():
h = u.replace("127.0.0.1", "host.docker.internal", 1)
print(f" {cam}: {h}", file=sys.stderr)
print("---", file=sys.stderr)
print(
"Fake RTSP running: each file plays once; script exits when ffmpeg ends "
"(Ctrl+C to stop early; MediaMTX container removed on exit).",
file=sys.stderr,
)
def on_sigint(_sig: int, _frame) -> None:
for p in procs:
if p.poll() is None:
p.terminate()
_stop_mediamtx_container()
raise SystemExit(130)
signal.signal(signal.SIGINT, on_sigint)
signal.signal(signal.SIGTERM, on_sigint)
try:
while True:
time.sleep(0.5)
for p in procs:
if p.poll() is not None:
print(
f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.",
file=sys.stderr,
)
raise KeyboardInterrupt
except KeyboardInterrupt:
pass
finally:
for p in procs:
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
_stop_mediamtx_container()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -10,7 +10,7 @@
<div class="app">
<header class="header">
<h1>手术监控 · 联调台</h1>
<p class="subtitle">条链路:真摄像头实时、模拟实时、离线精确处理</p>
<p class="subtitle">条链路:真摄像头 RTSP 切片 batch、离线精确处理</p>
<div class="top-grid">
<div>
@@ -46,11 +46,7 @@
<div class="mode-cards">
<button type="button" class="mode-card active" data-mode="live-rtsp">
<div class="title">链路 1 · 真摄像头</div>
<div class="desc">真实 RTSP · 实时算法 · 语音播报 · 需结束手术</div>
</button>
<button type="button" class="mode-card" data-mode="live-simulated">
<div class="title">链路 2 · 模拟实时</div>
<div class="desc">上传视频假流 · 实时算法 · 语音播报 · 需结束手术</div>
<div class="desc">RTSP 录像切片 · batch 算法 · 语音播报 · 需结束手术</div>
</button>
<button type="button" class="mode-card" data-mode="offline-batch">
<div class="title">链路 3 · 离线精确</div>
@@ -80,92 +76,9 @@
<h2>真摄像头配置</h2>
<label for="camera-ids">摄像头 ID逗号分隔</label>
<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">
<h2>模拟实时 · 上传视频</h2>
<label for="debug-stream-count">模拟路数</label>
<select id="debug-stream-count" style="max-width:8rem;margin-bottom:12px">
<option value="1" selected>1 路</option>
<option value="2">2 路</option>
<option value="3">3 路</option>
<option value="4">4 路</option>
</select>
<p class="labels-meta">每路须选择视频文件,将合成假 RTSP 后开录。</p>
<div class="stream-grid">
<div class="stream-slot" id="sim-stream-1">
<h4>路 1</h4>
<div class="upload-zone" id="sim-zone-1">
<div class="icon"></div>
<div id="sim-fname-1">点击或拖放视频</div>
<input type="file" id="sim-vfile-1" hidden accept="video/*" />
</div>
<div class="stream-actions">
<button type="button" class="secondary" id="sim-pick-1">选择文件</button>
<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>
<div class="upload-zone" id="sim-zone-2">
<div class="icon"></div>
<div id="sim-fname-2">点击或拖放视频</div>
<input type="file" id="sim-vfile-2" hidden accept="video/*" />
</div>
<div class="stream-actions">
<button type="button" class="secondary" id="sim-pick-2">选择文件</button>
<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>
<div class="upload-zone" id="sim-zone-3">
<div class="icon"></div>
<div id="sim-fname-3">点击或拖放视频</div>
<input type="file" id="sim-vfile-3" hidden accept="video/*" />
</div>
<div class="stream-actions">
<button type="button" class="secondary" id="sim-pick-3">选择文件</button>
<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>
<div class="upload-zone" id="sim-zone-4">
<div class="icon"></div>
<div id="sim-fname-4">点击或拖放视频</div>
<input type="file" id="sim-vfile-4" hidden accept="video/*" />
</div>
<div class="stream-actions">
<button type="button" class="secondary" id="sim-pick-4">选择文件</button>
<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">
<summary>高级:每路 RTSP 路径与 camera_id</summary>
<div class="advanced-body">
<div class="stream-grid">
<div><label>路 1 RTSP_PATH</label><input id="adv-rpath-1" type="text" value="demo1" /></div>
<div><label>路 1 camera_id</label><input id="adv-cam-1" type="text" value="or-cam-03" /></div>
<div><label>路 2 RTSP_PATH</label><input id="adv-rpath-2" type="text" value="demo2" /></div>
<div><label>路 2 camera_id</label><input id="adv-cam-2" type="text" value="or-cam-02" /></div>
<div><label>路 3 RTSP_PATH</label><input id="adv-rpath-3" type="text" value="demo3" /></div>
<div><label>路 3 camera_id</label><input id="adv-cam-3" type="text" value="or-cam-04" /></div>
<div><label>路 4 RTSP_PATH</label><input id="adv-rpath-4" type="text" value="demo4" /></div>
<div><label>路 4 camera_id</label><input id="adv-cam-4" type="text" value="or-cam-01" /></div>
</div>
</div>
</details>
<p id="live-rtsp-segment-hint" class="labels-meta" style="margin-top:8px">
默认仅录制 3 号机位or-cam-03并每 2 分钟切片跑 batch服务端落盘 slice 仅用于推理24 小时后自动删除(不影响已入库明细)。下方可预览每路画面。
</p>
</section>
<section class="card mode-panel" data-mode="offline-batch">
@@ -180,7 +93,7 @@
<button type="button" class="secondary" id="offline-pick" style="margin-top:10px">选择文件</button>
<label class="checkbox-row">
<input type="checkbox" id="offline-batch-include-vis" />
<span>生成标注视频24 小时内可预览)</span>
<span id="offline-batch-vis-label">生成标注视频24 小时内可预览)</span>
</label>
</section>