feat(demo): 模拟客户端与一键联调支持 1–4 路视频
- demo_orch: orchestrate-and-start 支持 video1 必填、video2–4 可选,扩展 camera/rtsp 参数 - demo_client/index.html: 路数选择、路 3/4 表单项、一键与 camera_ids 同步按路数 Made-with: Cursor
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user