From 8a6bfe9100b5778b3422362d38fb99bf01011793 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 24 Apr 2026 15:53:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(demo):=20=E6=A8=A1=E6=8B=9F=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E4=B8=8E=E4=B8=80=E9=94=AE=E8=81=94=E8=B0=83?= =?UTF-8?q?=E6=94=AF=E6=8C=81=201=E2=80=934=20=E8=B7=AF=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demo_orch: orchestrate-and-start 支持 video1 必填、video2–4 可选,扩展 camera/rtsp 参数 - demo_client/index.html: 路数选择、路 3/4 表单项、一键与 camera_ids 同步按路数 Made-with: Cursor --- app/routers/demo_orch.py | 92 ++++++++++++----- scripts/demo_client/index.html | 176 +++++++++++++++++++++++++-------- 2 files changed, 203 insertions(+), 65 deletions(-) diff --git a/app/routers/demo_orch.py b/app/routers/demo_orch.py index 2997dd7..15da6e2 100644 --- a/app/routers/demo_orch.py +++ b/app/routers/demo_orch.py @@ -1,4 +1,4 @@ -"""Dev-only: upload two videos, start synthetic RTSP, write RTSP URL file, then start surgery.""" +"""Dev-only: upload 1–4 videos, start synthetic RTSP, write RTSP URL file, then start surgery.""" from __future__ import annotations @@ -36,28 +36,34 @@ def _orchestrate_write_rtsp_host() -> str: @router.post( "/orchestrate-and-start", response_model=SurgeryApiResponse, - summary="一键联调:上传两路视频并开录", + summary="一键联调:上传 1–4 路视频并开录", description=( - "仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存两路视频、启动 MediaMTX+ffmpeg、" + "仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存一路或多路视频、启动 MediaMTX+ffmpeg、" "将 RTSP 映射写入 VIDEO_RTSP_URLS_JSON_FILE,再执行与 /client/surgeries/start 相同的开录逻辑。" ), ) async def orchestrate_and_start( - video1: Annotated[UploadFile, File(description="第一路视频")], - video2: Annotated[UploadFile, File(description="第二路视频")], surgery_id: Annotated[str, Form()], + video1: Annotated[UploadFile, File(description="第 1 路视频(必填,至少一路)")], + video2: Annotated[UploadFile | None, File(description="第 2 路视频(可选)")] = None, + video3: Annotated[UploadFile | None, File(description="第 3 路视频(可选)")] = None, + video4: Annotated[UploadFile | None, File(description="第 4 路视频(可选)")] = None, camera_1: Annotated[str, Form()] = "or-cam-01", camera_2: Annotated[str, Form()] = "or-cam-02", + camera_3: Annotated[str, Form()] = "or-cam-03", + camera_4: Annotated[str, Form()] = "or-cam-04", rtsp_path_1: Annotated[str, Form()] = "demo1", rtsp_path_2: Annotated[str, Form()] = "demo2", + rtsp_path_3: Annotated[str, Form()] = "demo3", + rtsp_path_4: Annotated[str, Form()] = "demo4", candidate_consumables_json: Annotated[str, Form()] = "[]", pipeline: SurgeryPipeline = Depends(get_surgery_pipeline), ) -> SurgeryApiResponse: logger.info( - "demo orchestrate-and-start: surgery_id={} cameras={} {}", + "demo orchestrate-and-start: surgery_id={} cameras={} rpaths={}", surgery_id, - (camera_1, camera_2), - f"rpaths=({rtsp_path_1},{rtsp_path_2})", + (camera_1, camera_2, camera_3, camera_4), + (rtsp_path_1, rtsp_path_2, rtsp_path_3, rtsp_path_4), ) if not settings.demo_orchestrator_enabled: raise HTTPException( @@ -88,10 +94,53 @@ async def orchestrate_and_start( detail="candidate_consumables_json must be a JSON array of strings", ) + default_rtsp = ("demo1", "demo2", "demo3", "demo4") + + async def _bytes_and_suffix(u: UploadFile) -> tuple[bytes, str]: + raw = await u.read() + ext = Path(u.filename or "clip.mp4").suffix or ".mp4" + return raw, ext + + slot_uploads = (video1, video2, video3, video4) + slot_cameras = ( + camera_1.strip(), + camera_2.strip(), + camera_3.strip(), + camera_4.strip(), + ) + slot_rpaths = ( + rtsp_path_1.strip(), + rtsp_path_2.strip(), + rtsp_path_3.strip(), + rtsp_path_4.strip(), + ) + + gathered: list[tuple[bytes, str, str, str]] = [] + for idx, u in enumerate(slot_uploads): + if u is None: + break + raw, ext = await _bytes_and_suffix(u) + if not raw: + break + cam = slot_cameras[idx] or f"or-cam-0{idx + 1}" + rp = slot_rpaths[idx] or default_rtsp[idx] + gathered.append((raw, ext, cam, rp)) + + if not gathered: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="至少需要一路非空视频(video1)", + ) + if len(gathered) > 4: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="最多 4 路视频", + ) + try: body = SurgeryStartRequest( surgery_id=surgery_id, - camera_ids=[camera_1.strip(), camera_2.strip()], + camera_ids=[g[2] for g in gathered], candidate_consumables=[str(x) for x in candidates], ) except Exception as exc: @@ -100,24 +149,13 @@ async def orchestrate_and_start( detail=str(exc), ) from exc - ext1 = Path(video1.filename or "a.mp4").suffix or ".mp4" - ext2 = Path(video2.filename or "b.mp4").suffix or ".mp4" - v1_bytes = await video1.read() - v2_bytes = await video2.read() - if not v1_bytes or not v2_bytes: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Video files must be non-empty", - ) - work_root = Path(tempfile.mkdtemp(prefix="orm-orch-")) try: - fp1 = work_root / f"v1{ext1}" - fp2 = work_root / f"v2{ext2}" def _save_files() -> None: - fp1.write_bytes(v1_bytes) - fp2.write_bytes(v2_bytes) + for i, (raw, ext, _cam, _rp) in enumerate(gathered): + fp = work_root / f"v{i + 1}{ext}" + fp.write_bytes(raw) await anyio.to_thread.run_sync(_save_files) except OSError as exc: @@ -127,8 +165,12 @@ async def orchestrate_and_start( ) from exc streams = [ - StreamSpec(camera_id=body.camera_ids[0], file_path=fp1, rtsp_path=rtsp_path_1.strip() or "demo1"), - StreamSpec(camera_id=body.camera_ids[1], file_path=fp2, rtsp_path=rtsp_path_2.strip() or "demo2"), + StreamSpec( + camera_id=g[2], + file_path=work_root / f"v{i + 1}{g[1]}", + rtsp_path=g[3], + ) + for i, g in enumerate(gathered) ] port = int(settings.demo_orchestrator_rtsp_port) diff --git a/scripts/demo_client/index.html b/scripts/demo_client/index.html index e2821c1..40869f8 100644 --- a/scripts/demo_client/index.html +++ b/scripts/demo_client/index.html @@ -239,14 +239,25 @@
-

调试:两路视频(与一键联调 / 无真摄像头)

+

调试:多路视频 1–4 路(与一键联调 / 无真摄像头)

- 在路1 / 路2选好视频、§4.1 勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并写 VIDEO_RTSP_URLS_JSON_FILE。无法使用一键时,请按 scripts/demo_client/README.md 在宿主机手跑 + 在下方选好各路视频、第 4.1 节勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并写 VIDEO_RTSP_URLS_JSON_FILE。无法使用一键时,请按 scripts/demo_client/README.md 在宿主机手跑 fake_rtsp_from_file.py 并配置环境变量。

-

两路视频(为 §4.1 一键选文件;两路 RTSP_PATH / camera_id 须与 API 配置一致,如 demo1 / demo2

-
-
+
+
+ + +
+
+

各路视频(为第 4.1 节一键选文件;每路 RTSP_PATH 须不同,camera_id 须与开录时一致)

+
+

路 1

@@ -257,7 +268,7 @@
- +
@@ -266,7 +277,7 @@
-
+

路 2

@@ -286,12 +297,52 @@
+ +

- 一键联调会直接上传你在此为路1/路2选择的文件。选文件时会把框内填成 ./文件名,仅作展示;真正上传以文件选择器为准,无需在框里改路径。 + 一键联调会直接上传你在此为各路选择的文件。选文件时会把框内填成 ./文件名,仅作展示;真正上传以文件选择器为准,无需在框里改路径。

- +
@@ -312,7 +363,7 @@

@@ -621,20 +672,35 @@ const sid = ensureSurgeryId(); if (!sid) return; if ($("orch-oneclick") && $("orch-oneclick").checked) { - const f1 = $("debug-vfile-1").files[0]; - const f2 = $("debug-vfile-2").files[0]; - if (!f1 || !f2) { - alert("请先在上方「调试」里为 路1 / 路2 各「选择…」一个视频文件。"); + const n = getDebugStreamCount(); + const files = []; + for (let i = 1; i <= n; i++) { + const f = $("debug-vfile-" + i).files && $("debug-vfile-" + i).files[0]; + files.push(f); + } + const missing = files.findIndex((x) => !x); + if (missing >= 0) { + alert("请先在上方「调试」里为 路 " + (missing + 1) + " 「选择…」一个视频文件(当前为 " + n + " 路)。"); return; } const fd = new FormData(); - fd.append("video1", f1, f1.name); - fd.append("video2", f2, f2.name); fd.append("surgery_id", sid); - fd.append("camera_1", ($("debug-cam-1").value || "or-cam-01").trim() || "or-cam-01"); - fd.append("camera_2", ($("debug-cam-2").value || "or-cam-02").trim() || "or-cam-02"); - fd.append("rtsp_path_1", ($("debug-rpath-1").value || "demo1").trim() || "demo1"); - fd.append("rtsp_path_2", ($("debug-rpath-2").value || "demo2").trim() || "demo2"); + 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); + const defCams = ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"]; + const defRp = ["demo1", "demo2", "demo3", "demo4"]; + for (let i = 1; i <= 4; i++) { + fd.append( + "camera_" + i, + ($("debug-cam-" + i).value || defCams[i - 1]).trim() || defCams[i - 1], + ); + fd.append( + "rtsp_path_" + i, + ($("debug-rpath-" + i).value || defRp[i - 1]).trim() || defRp[i - 1], + ); + } fd.append("candidate_consumables_json", JSON.stringify([...tags])); const { res, body } = await apiMultipart("/internal/demo/orchestrate-and-start", fd); if (!res.ok) { @@ -1194,27 +1260,56 @@ }; // ============================================================ - // Debug: two streams for one-click upload (路1/路2) + // Debug: 1–4 streams for one-click upload // ============================================================ - $("btn-dbg-pick-1").onclick = () => $("debug-vfile-1").click(); - $("debug-vfile-1").addEventListener("change", (e) => { - const f = e.target.files && e.target.files[0]; - if (!f) return; - $("debug-vpath-1").value = "./" + f.name; - $("debug-hint-1").textContent = "已选: " + f.name; - }); - $("btn-dbg-pick-2").onclick = () => $("debug-vfile-2").click(); - $("debug-vfile-2").addEventListener("change", (e) => { - const f = e.target.files && e.target.files[0]; - if (!f) return; - $("debug-vpath-2").value = "./" + f.name; - $("debug-hint-2").textContent = "已选: " + f.name; - }); + function getDebugStreamCount() { + const sel = $("debug-stream-count"); + const v = sel ? parseInt(sel.value, 10) : 2; + if (v === 1 || v === 2 || v === 3 || v === 4) return v; + return 2; + } + + function applyDebugStreamVisibility() { + const n = getDebugStreamCount(); + for (let i = 1; i <= 4; i++) { + const el = $("debug-stream-" + i); + if (!el) continue; + el.style.display = i <= n ? "block" : "none"; + } + } + + if ($("debug-stream-count")) { + $("debug-stream-count").addEventListener("change", () => { + applyDebugStreamVisibility(); + }); + applyDebugStreamVisibility(); + } + + for (let i = 1; i <= 4; i++) { + const pick = $("btn-dbg-pick-" + i); + const vfile = $("debug-vfile-" + i); + const vpath = $("debug-vpath-" + i); + const hint = $("debug-hint-" + i); + if (pick && vfile) { + pick.onclick = () => vfile.click(); + vfile.addEventListener("change", (e) => { + const f = e.target.files && e.target.files[0]; + if (!f) return; + vpath.value = "./" + f.name; + hint.textContent = "已选: " + f.name; + }); + } + } $("btn-debug-apply-cams").onclick = () => { - const a = ($("debug-cam-1").value || "or-cam-01").trim() || "or-cam-01"; - const b = ($("debug-cam-2").value || "or-cam-02").trim() || "or-cam-02"; - $("camera-ids").value = a + "," + b; + const defCams = ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"]; + const n = getDebugStreamCount(); + const parts = []; + for (let i = 1; i <= n; i++) { + const a = ($("debug-cam-" + i).value || defCams[i - 1]).trim() || defCams[i - 1]; + parts.push(a); + } + $("camera-ids").value = parts.join(","); }; (function setupDebugVideoDrop() { @@ -1243,8 +1338,9 @@ $(hintId).textContent = "已选: " + f.name + "(拖放)"; }); } - bindStreamCard($("debug-stream-1"), "debug-vpath-1", "debug-hint-1"); - bindStreamCard($("debug-stream-2"), "debug-vpath-2", "debug-hint-2"); + for (let i = 1; i <= 4; i++) { + bindStreamCard($("debug-stream-" + i), "debug-vpath-" + i, "debug-hint-" + i); + } })(); // ============================================================