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 / 路2选好视频、§4.1 勾选「一键联调」后点「开始手术」即可;服务端会起假 RTSP 并写
- 一键联调会直接上传你在此为路1/路2选择的文件。选文件时会把框内填成 调试:两路视频(与一键联调 / 无真摄像头)
+ 调试:多路视频 1–4 路(与一键联调 / 无真摄像头)
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 @@
路 2
@@ -286,12 +297,52 @@
./文件名,仅作展示;真正上传以文件选择器为准,无需在框里改路径。
+ 一键联调会直接上传你在此为各路选择的文件。选文件时会把框内填成 ./文件名,仅作展示;真正上传以文件选择器为准,无需在框里改路径。