feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调

- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 11:21:16 +08:00
parent 4c3f9a367b
commit 6b3adb4ad8
36 changed files with 1194 additions and 162 deletions

View File

@@ -33,7 +33,7 @@ python3 scripts/demo_client/fake_rtsp_from_file.py --port 18554 \
--stream 'or-cam-02|./b.mp4|demo2'
```
`--stream` 格式为 `CAMERA_ID|文件路径|RTSP_PATH`(竖线分隔,整条加引号),生成的 `VIDEO_RTSP_URLS_JSON` 会同时包含 `or-cam-01``or-cam-02`
`--stream` 格式为 `CAMERA_ID|文件路径|RTSP_PATH`(竖线分隔,整条加引号),脚本会在 stderr 打印含 `video_rtsp_urls``voice_or_room_bindings: []`**站点 JSON 片段**,可合并进 `OR_SITE_CONFIG_JSON_FILE`
在**另一终端**启动监控服务前 `source` 或手动 `export` 上述变量,使 `POST /client/surgeries/start` 里使用的 `camera_ids`(如 `or-cam-01,or-cam-02`)能解析到对应 URL。Demo 页里「将 camera_id 填到开始手术」可一键同步两路 id。
@@ -46,7 +46,7 @@ python3 scripts/demo_client/fake_rtsp_from_file.py --port 18554 \
- 给该服务容器加 `--add-host=host.docker.internal:host-gateway`Docker 20.10+),或
- 直接把 URL 写成宿主在 **docker0/桥接网** 上可达的局域网 IP`192.168.x.x`),保证从容器内 `curl`/`ffprobe` 能通
`docker-compose` 里可将 `VIDEO_RTSP_URLS_JSON` 写进 `environment:` 或 env 文件;**不要**在仅容器可解析的配置里写 `127.0.0.1` 去指宿主机上的 RTSP`127.0.0.1` 在容器内是容器自己)。
生产/容器环境请使用 **`OR_SITE_CONFIG_JSON_FILE`** 指向完整站点 JSON`video_rtsp_urls` `voice_or_room_bindings`)。**不要**在仅容器可解析的配置里写 `127.0.0.1` 去指宿主机上的 RTSP`127.0.0.1` 在容器内是容器自己)。
若监控与假 RTSP **都在宿主机同一系统**里直接跑(非容器),则用 `rtsp://127.0.0.1:...` 即可;否则应使用上面「容器连宿主」的写法。
@@ -58,7 +58,7 @@ ffmpeg -re -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transpor
(仍须先自行启动 MediaMTX 或等价 RTSP 服务端;上例为**播完即止**,若要循环请加 `-stream_loop -1`。)
Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传,无需在页面里手抄 `python3` / `export` 命令。若必须完全手跑 `fake_rtsp_from_file.py`,请在上文命令示例与 `export VIDEO_RTSP_URLS_JSON=...` 方式自行在终端完成
Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传。若必须完全手跑 `fake_rtsp_from_file.py`,请将其打印的站点 JSON 合并进 `OR_SITE_CONFIG_JSON_FILE`
## 一键开录(不再手抄命令)
@@ -66,13 +66,13 @@ Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放**
1. 落盘两路视频到临时目录
2. 用 Docker 起 MediaMTX、两路 ffmpeg 推 RTSP`fake_rtsp_from_file.py` 等效)
3. `{"or-cam-01":"rtsp://127.0.0.1:…","or-cam-02":"rtsp://127.0.0.1:…"}` 写入 `VIDEO_RTSP_URLS_JSON_FILE`(与开录/拉流同进程,固定本机回环;`DEMO_ORCHESTRATOR_RTSP_JSON_HOST` 仅影响你**手配**假流、给另一进程读 JSON 的用法
3.当前假流的 **video_rtsp_urls** 合并写入 `OR_SITE_CONFIG_JSON_FILE`(保留已有 `voice_or_room_bindings`;与开录/拉流同进程,固定本机回环
4. 调用与普通开录相同逻辑
**需同时满足**
- `.env``DEMO_ORCHESTRATOR_ENABLED=true`(并重启 API
- 已设置 `VIDEO_RTSP_URLS_JSON_FILE` 指向**可写**的 JSON 文件Docker 中请用 **bind-mount** 到容器内同一路径
- 已设置 `OR_SITE_CONFIG_JSON_FILE` 指向**可写**的站点 JSONDocker 中请用 **bind-mount** 到容器内同一路径
- **运行 `main.py` 的进程**能执行本机 `docker``ffmpeg`(与手动跑 `fake_rtsp_from_file` 相同)。**仅将 API 放 Docker、且不挂载** ` /var/run/docker.sock` 时,容器内往往无法为你在宿主机起 MediaMTX此时请继续用手动假流方式。
由于每次解析都会重新读取 `video_rtsp_url_map()`,覆盖 JSON 后**无需重启**主服务即可被下一次开录用到。

View File

@@ -3,7 +3,7 @@
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) so you can point VIDEO_RTSP_URLS_JSON to rtsp://.../yourpath.
RTSP server (MediaMTX); put the printed ``video_rtsp_urls`` into ``OR_SITE_CONFIG_JSON_FILE``.
Requires:
- ffmpeg in PATH
@@ -210,14 +210,18 @@ def main() -> int:
p = subprocess.Popen(publish_cmd) # noqa: S603
procs.append(p)
j_compact = json.dumps(url_map, ensure_ascii=False, separators=(",", ":"))
site_doc = {"video_rtsp_urls": url_map, "voice_or_room_bindings": []}
print("---", file=sys.stderr)
print("RTSP mapping (set on monitoring server):", 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("export (same machine as monitoring server, env snippet):", file=sys.stderr)
print(f" export VIDEO_RTSP_URLS_JSON='{j_compact}'", 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():

View File

@@ -231,6 +231,12 @@
<input id="surgery-id" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" value="123456" />
</div>
</div>
<p class="small muted" style="margin-top:10px">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="toggle-browser-voice-ui" style="margin-top:2px" />
<span>显示<strong> §4.4 / §4.5</strong>(浏览器待确认与录音上传;默认关闭,主流程请用桌面语音客户端)</span>
</label>
</p>
<div class="actions">
<button id="btn-health" class="secondary">GET /health</button>
<button type="button" class="secondary" id="btn-orch-status" title="检查一键联调接口是否已注册">GET 联调状态</button>
@@ -241,7 +247,7 @@
<section class="card">
<h2>调试:多路视频 14 路(与一键联调 / 无真摄像头)</h2>
<p class="callout-ok small">
在下方选好各路视频、第 4.1 节勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并 <code>VIDEO_RTSP_URLS_JSON_FILE</code>。无法使用一键时,请按 <code>scripts/demo_client/README.md</code> 在宿主机手跑
在下方选好各路视频、第 4.1 节勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并合并写入 <code>OR_SITE_CONFIG_JSON_FILE</code><code>video_rtsp_urls</code>。无法使用一键时,请按 <code>scripts/demo_client/README.md</code> 在宿主机手跑
<code>fake_rtsp_from_file.py</code> 并配置环境变量。
</p>
<div class="row" style="margin-top:8px; max-width:28rem">
@@ -363,7 +369,7 @@
<p class="small muted" style="margin:8px 0 0">
<label style="display:inline-flex;align-items:flex-start;gap:8px;cursor:pointer;max-width:52rem">
<input type="checkbox" id="orch-oneclick" style="margin-top:2px" />
<span><strong>一键联调</strong>:点下面按钮时按「模拟路数」上传调试区为<strong>路 1…N</strong>选好的视频14 路),由监控服务在<strong>能执行 docker+ffmpeg 的环境</strong>里自动起假 RTSP、 <code>VIDEO_RTSP_URLS_JSON_FILE</code> 并开录(需 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 且该文件为可写挂载;详见 README。不勾选时仍为普通 JSON 开录(需自行先起假流)。</span>
<span><strong>一键联调</strong>:点下面按钮时按「模拟路数」上传调试区为<strong>路 1…N</strong>选好的视频14 路),由监控服务在<strong>能执行 docker+ffmpeg 的环境</strong>里自动起假 RTSP、更新 <code>OR_SITE_CONFIG_JSON_FILE</code> 并开录(需 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 且该文件为可写挂载;详见 README。不勾选时仍为普通开录需自行先起假流并保证站点 JSON 中 RTSP 映射正确)。</span>
</label>
</p>
<div class="actions">
@@ -388,6 +394,7 @@
<div id="result-render"></div>
</section>
<div id="browser-voice-sections" hidden>
<section class="card">
<h2>§4.4 待确认耗材</h2>
<div class="actions">
@@ -427,6 +434,7 @@
<a id="btn-download" class="small muted" href="#" download="voice.wav" style="display:none">下载 WAV调试</a>
</div>
</section>
</div>
</main>
<aside>
@@ -483,6 +491,17 @@
[...logEl.querySelectorAll(".log-item")].forEach(n => n.remove());
};
const browserVoiceSections = $("browser-voice-sections");
const toggleBrowserVoiceUi = $("toggle-browser-voice-ui");
function syncBrowserVoiceUiVisibility() {
if (!browserVoiceSections || !toggleBrowserVoiceUi) return;
browserVoiceSections.hidden = !toggleBrowserVoiceUi.checked;
}
if (toggleBrowserVoiceUi) {
toggleBrowserVoiceUi.addEventListener("change", syncBrowserVoiceUiVisibility);
syncBrowserVoiceUiVisibility();
}
async function apiJson(method, path, payload) {
const url = baseUrl() + path;
let res;
@@ -523,8 +542,8 @@
let hint = "";
if (res.status === 404) {
hint = "HTTP 404本路径在服务端未注册。常见原因1) 未设 DEMO_ORCHESTRATOR_ENABLED=true 并重启主进程POST /internal/demo/orchestrate-and-start 未挂载2)「服务端 Base URL」填错须指向主 API 如 http://127.0.0.1:38080不是本 demo 静态站 :38081。可点「GET 联调状态」或打开浏览器控制台查看 [demo-client] 日志。";
} else if (res.status === 400 && parsed && (parsed.detail || "").toString().indexOf("VIDEO_RTSP") >= 0) {
hint = "需配置可写的 VIDEO_RTSP_URLS_JSON_FILE,且 Docker 下请 bind-mount 到容器内同路径。";
} else if (res.status === 400 && parsed && (parsed.detail || "").toString().indexOf("OR_SITE_CONFIG") >= 0) {
hint = "需配置可写的 OR_SITE_CONFIG_JSON_FILE严格站点 JSON,且 Docker 下请 bind-mount 到容器内同路径。";
} else if (res.status === 503) {
hint = "合成假 RTSP 或开录失败,请见响应体与主服务终端 logdemo orchestrate-and-start / ffmpeg / docker。";
}
@@ -640,12 +659,12 @@
return;
}
const on = data.orchestrator_enabled === true;
const fset = data.video_rtsp_urls_json_file_set === true;
const fset = data.or_site_config_json_file_set === true;
b.style.background = on && fset ? "rgba(34, 197, 94, 0.1)" : "rgba(245, 158, 11, 0.12)";
b.style.color = "var(--text)";
const fp = data.video_rtsp_urls_json_file || "(未设)";
const fp = data.or_site_config_json_file || "(未设)";
b.innerHTML = on
? ("一键 <code>POST " + (data.orchestrate_path || "/internal/demo/orchestrate-and-start") + "</code>" + (fset ? "已开放;RTSP 映射文件 " : "未设 ") + "<code>" + fp + "</code>")
? ("一键 <code>POST " + (data.orchestrate_path || "/internal/demo/orchestrate-and-start") + "</code>" + (fset ? "已开放;站点配置 " : "未设 ") + "<code>" + fp + "</code>")
: ("一键开录 <strong>未注册</strong>:请在主服务 .env 设 <code>DEMO_ORCHESTRATOR_ENABLED=true</code> 并<strong>重启</strong>。当前 " + (data.orchestrate_path || "") + " 会 404。");
} catch (e) {
console.error("[demo-client] orchestrator-status failed", e);