2026-04-24 15:53:32 +08:00
|
|
|
|
"""Dev-only: upload 1–4 videos, start synthetic RTSP, write RTSP URL file, then start surgery."""
|
2026-04-23 14:24:20 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Annotated
|
|
|
|
|
|
|
|
|
|
|
|
import anyio
|
|
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
|
|
from app.config import settings
|
2026-04-27 11:21:16 +08:00
|
|
|
|
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
|
2026-04-23 14:24:20 +08:00
|
|
|
|
from app.schemas import SurgeryApiResponse, SurgeryStartRequest
|
2026-04-27 11:21:16 +08:00
|
|
|
|
from app.or_site_config import merge_video_rtsp_urls_into_file
|
|
|
|
|
|
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager
|
2026-04-23 14:24:20 +08:00
|
|
|
|
from app.services.surgery_pipeline import SurgeryPipeline
|
2026-04-27 11:21:16 +08:00
|
|
|
|
from app.services.voice_terminal_hub import (
|
|
|
|
|
|
VoiceTerminalHub,
|
|
|
|
|
|
assign_voice_terminal_after_recording_started,
|
|
|
|
|
|
)
|
2026-04-23 14:24:20 +08:00
|
|
|
|
from app.surgery_errors import SurgeryPipelineError
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/internal/demo", tags=["demo"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _orchestrate_write_rtsp_host() -> str:
|
|
|
|
|
|
"""Write JSON 里用于 RTSP 的主机名。
|
|
|
|
|
|
|
|
|
|
|
|
一键在本进程起 MediaMTX(端口映射在**本机网络命名空间**的 127.0.0.1)并拉流,OpenCV
|
|
|
|
|
|
必须连 ``rtsp://127.0.0.1:port/...``。若改写成 ``host.docker.internal``,会指到
|
|
|
|
|
|
宿主机上的同端口,通常没有这路流,故 DESCRIBE 返回 404。
|
|
|
|
|
|
`DEMO_ORCHESTRATOR_RTSP_JSON_HOST` 对此路由无效;手填假流+仅改 JSON 的拓扑仍可用该配置。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return "127.0.0.1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
|
"/orchestrate-and-start",
|
|
|
|
|
|
response_model=SurgeryApiResponse,
|
2026-04-24 15:53:32 +08:00
|
|
|
|
summary="一键联调:上传 1–4 路视频并开录",
|
2026-04-23 14:24:20 +08:00
|
|
|
|
description=(
|
2026-04-24 15:53:32 +08:00
|
|
|
|
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存一路或多路视频、启动 MediaMTX+ffmpeg、"
|
2026-04-27 11:21:16 +08:00
|
|
|
|
"将 RTSP 映射合并写入 OR_SITE_CONFIG_JSON_FILE 的 video_rtsp_urls,再执行与 /client/surgeries/start 相同的开录逻辑"
|
|
|
|
|
|
"(含按 voice_or_room_bindings 解析并 WebSocket 推送语音终端指派)。"
|
2026-04-23 14:24:20 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
async def orchestrate_and_start(
|
|
|
|
|
|
surgery_id: Annotated[str, Form()],
|
2026-04-24 15:53:32 +08:00
|
|
|
|
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,
|
2026-04-23 14:24:20 +08:00
|
|
|
|
camera_1: Annotated[str, Form()] = "or-cam-01",
|
|
|
|
|
|
camera_2: Annotated[str, Form()] = "or-cam-02",
|
2026-04-24 15:53:32 +08:00
|
|
|
|
camera_3: Annotated[str, Form()] = "or-cam-03",
|
|
|
|
|
|
camera_4: Annotated[str, Form()] = "or-cam-04",
|
2026-04-23 14:24:20 +08:00
|
|
|
|
rtsp_path_1: Annotated[str, Form()] = "demo1",
|
|
|
|
|
|
rtsp_path_2: Annotated[str, Form()] = "demo2",
|
2026-04-24 15:53:32 +08:00
|
|
|
|
rtsp_path_3: Annotated[str, Form()] = "demo3",
|
|
|
|
|
|
rtsp_path_4: Annotated[str, Form()] = "demo4",
|
2026-04-23 14:24:20 +08:00
|
|
|
|
candidate_consumables_json: Annotated[str, Form()] = "[]",
|
|
|
|
|
|
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
|
2026-04-27 11:21:16 +08:00
|
|
|
|
voice_hub: VoiceTerminalHub = Depends(get_voice_terminal_hub),
|
2026-04-23 14:24:20 +08:00
|
|
|
|
) -> SurgeryApiResponse:
|
|
|
|
|
|
logger.info(
|
2026-04-24 15:53:32 +08:00
|
|
|
|
"demo orchestrate-and-start: surgery_id={} cameras={} rpaths={}",
|
2026-04-23 14:24:20 +08:00
|
|
|
|
surgery_id,
|
2026-04-24 15:53:32 +08:00
|
|
|
|
(camera_1, camera_2, camera_3, camera_4),
|
|
|
|
|
|
(rtsp_path_1, rtsp_path_2, rtsp_path_3, rtsp_path_4),
|
2026-04-23 14:24:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
if not settings.demo_orchestrator_enabled:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
|
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
|
|
|
|
|
|
)
|
2026-04-27 11:21:16 +08:00
|
|
|
|
path_raw = (settings.or_site_config_json_file or "").strip()
|
2026-04-23 14:24:20 +08:00
|
|
|
|
if not path_raw:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
|
detail=(
|
2026-04-27 11:21:16 +08:00
|
|
|
|
"OR_SITE_CONFIG_JSON_FILE must be set to a writable path "
|
|
|
|
|
|
"(strict site JSON with video_rtsp_urls + voice_or_room_bindings); "
|
2026-04-23 14:24:20 +08:00
|
|
|
|
"in Docker, bind-mount a host file to this path."
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
json_path = Path(path_raw).expanduser()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
candidates = json.loads(candidate_consumables_json)
|
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
|
|
|
|
detail=f"invalid candidate_consumables_json: {exc}",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
if not isinstance(candidates, list) or not all(isinstance(x, str) for x in candidates):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
|
|
|
|
detail="candidate_consumables_json must be a JSON array of strings",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-24 15:53:32 +08:00
|
|
|
|
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 路视频",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-23 14:24:20 +08:00
|
|
|
|
try:
|
|
|
|
|
|
body = SurgeryStartRequest(
|
|
|
|
|
|
surgery_id=surgery_id,
|
2026-04-24 15:53:32 +08:00
|
|
|
|
camera_ids=[g[2] for g in gathered],
|
2026-04-23 14:24:20 +08:00
|
|
|
|
candidate_consumables=[str(x) for x in candidates],
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
|
|
|
|
detail=str(exc),
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
work_root = Path(tempfile.mkdtemp(prefix="orm-orch-"))
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
|
|
|
|
def _save_files() -> None:
|
2026-04-24 15:53:32 +08:00
|
|
|
|
for i, (raw, ext, _cam, _rp) in enumerate(gathered):
|
|
|
|
|
|
fp = work_root / f"v{i + 1}{ext}"
|
|
|
|
|
|
fp.write_bytes(raw)
|
2026-04-23 14:24:20 +08:00
|
|
|
|
|
|
|
|
|
|
await anyio.to_thread.run_sync(_save_files)
|
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
|
detail=f"failed to save uploads: {exc}",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
streams = [
|
2026-04-24 15:53:32 +08:00
|
|
|
|
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)
|
2026-04-23 14:24:20 +08:00
|
|
|
|
]
|
|
|
|
|
|
port = int(settings.demo_orchestrator_rtsp_port)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
|
|
|
|
def _start_synth() -> dict[str, str]:
|
|
|
|
|
|
mgr = SyntheticRtspManager.get()
|
|
|
|
|
|
_run, url_map = mgr.start(streams, host_port=port, work_dir=work_root)
|
|
|
|
|
|
return url_map
|
|
|
|
|
|
|
|
|
|
|
|
url_map_host = await anyio.to_thread.run_sync(_start_synth)
|
|
|
|
|
|
except (FileNotFoundError, OSError, ValueError, RuntimeError) as exc:
|
|
|
|
|
|
logger.exception("synthetic RTSP start failed: {}", exc)
|
|
|
|
|
|
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
|
|
|
|
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
|
|
|
|
detail=f"synthetic RTSP failed: {exc}",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
host_for_json = _orchestrate_write_rtsp_host()
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
|
|
|
|
def _write() -> None:
|
2026-04-27 11:21:16 +08:00
|
|
|
|
merge_video_rtsp_urls_into_file(
|
2026-04-23 14:24:20 +08:00
|
|
|
|
json_path,
|
|
|
|
|
|
url_map_host,
|
|
|
|
|
|
replace_host=host_for_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await anyio.to_thread.run_sync(_write)
|
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
|
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
|
detail=f"failed to write RTSP JSON file: {exc}",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
await anyio.sleep(0.2)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await pipeline.start_recording(
|
|
|
|
|
|
body.surgery_id,
|
|
|
|
|
|
list(body.camera_ids),
|
|
|
|
|
|
list(body.candidate_consumables),
|
|
|
|
|
|
)
|
|
|
|
|
|
except SurgeryPipelineError as exc:
|
|
|
|
|
|
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
|
|
|
|
detail={"code": exc.code, "message": exc.message, "surgery_id": body.surgery_id},
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
2026-04-27 11:21:16 +08:00
|
|
|
|
await assign_voice_terminal_after_recording_started(
|
|
|
|
|
|
voice_hub,
|
|
|
|
|
|
surgery_id=body.surgery_id,
|
|
|
|
|
|
camera_ids=list(body.camera_ids),
|
|
|
|
|
|
set_voice_terminal_id=pipeline.set_voice_terminal_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-23 14:24:20 +08:00
|
|
|
|
return SurgeryApiResponse(
|
|
|
|
|
|
surgery_id=body.surgery_id,
|
|
|
|
|
|
status="accepted",
|
|
|
|
|
|
message="假 RTSP 已起;映射已写入;摄像头录制已开始。",
|
|
|
|
|
|
)
|