"""Dev-only: upload two videos, start synthetic RTSP, write RTSP URL file, then start surgery.""" 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 from app.dependencies import get_surgery_pipeline from app.schemas import SurgeryApiResponse, SurgeryStartRequest from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager, write_rtsp_url_json_file from app.services.surgery_pipeline import SurgeryPipeline 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, summary="一键联调:上传两路视频并开录", description=( "仅当 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()], camera_1: Annotated[str, Form()] = "or-cam-01", camera_2: Annotated[str, Form()] = "or-cam-02", rtsp_path_1: Annotated[str, Form()] = "demo1", rtsp_path_2: Annotated[str, Form()] = "demo2", candidate_consumables_json: Annotated[str, Form()] = "[]", pipeline: SurgeryPipeline = Depends(get_surgery_pipeline), ) -> SurgeryApiResponse: logger.info( "demo orchestrate-and-start: surgery_id={} cameras={} {}", surgery_id, (camera_1, camera_2), f"rpaths=({rtsp_path_1},{rtsp_path_2})", ) if not settings.demo_orchestrator_enabled: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).", ) path_raw = (settings.video_rtsp_urls_json_file or "").strip() if not path_raw: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( "VIDEO_RTSP_URLS_JSON_FILE must be set to a writable path; " "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", ) try: body = SurgeryStartRequest( surgery_id=surgery_id, camera_ids=[camera_1.strip(), camera_2.strip()], 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 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) 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 = [ 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"), ] 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: write_rtsp_url_json_file( 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 return SurgeryApiResponse( surgery_id=body.surgery_id, status="accepted", message="假 RTSP 已起;映射已写入;摄像头录制已开始。", )