将 Demo 录制收敛为三条独立链路,并重做联调台 UI。

移除 demo_orch 统一编排,改为 recording_demo 与 live/simulated 服务;客户端拆分为静态资源,以模式卡片与 chip 耗材覆盖三链路联调,并同步测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-21 16:50:23 +08:00
parent 09885b4184
commit 153c91f8ff
16 changed files with 2030 additions and 1364 deletions

View File

@@ -1,10 +1,8 @@
"""Dev-only: upload 14 videos, start synthetic RTSP, write RTSP URL file, then start surgery."""
"""Demo 录制模式:链路 2 模拟实时、链路 3 离线 batch需 DEMO_ORCHESTRATOR_ENABLED"""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Annotated
@@ -18,10 +16,14 @@ from app.config import settings
from app.consumable_catalog import normalize_candidate_consumables_raw
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
from app.schemas import SurgeryApiResponse, SurgeryStartRequest
from app.or_site_config import merge_video_rtsp_urls_into_file
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager
from app.services.recording_live import accept_live_recording
from app.services.simulated_rtsp_setup import (
prepare_simulated_rtsp_streams,
read_simulated_stream_uploads,
)
from app.services.surgery_pipeline import SurgeryPipeline
from app.baked import pipeline as bp
from app.services.synthetic_rtsp import SyntheticRtspManager
from app.services.video_batch_cleanup import (
purge_batch_artifacts,
purge_expired_visualizations,
@@ -29,16 +31,35 @@ from app.services.video_batch_cleanup import (
stage_visualization_pending,
)
from app.services.video_batch_runner import VideoBatchRunner
from app.services.voice_terminal_hub import (
VoiceTerminalHub,
assign_voice_terminal_after_recording_started,
)
from app.services.voice_terminal_hub import VoiceTerminalHub
from app.surgery_errors import SurgeryPipelineError
router = APIRouter(prefix="/internal/demo", tags=["demo"])
# Bumped when video-batch flow changes; grep this string in logs after restart to confirm new code.
VIDEO_BATCH_FLOW_MARKER = "purge-all+opt-in-vis-v4"
# Grep in logs after restart to confirm new offline-batch code is loaded.
OFFLINE_BATCH_FLOW_MARKER = "offline-batch-v5"
def _require_demo_orchestrator() -> None:
if not settings.demo_orchestrator_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo recording modes disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
def _require_site_config_path() -> Path:
path_raw = (settings.or_site_config_json_file or "").strip()
if not path_raw:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"OR_SITE_CONFIG_JSON_FILE must be set to a writable path "
"(strict site JSON with video_rtsp_urls + voice_or_room_bindings); "
"in Docker, bind-mount a host file to this path."
),
)
return Path(path_raw).expanduser()
def _background_finalize_visualization(
@@ -48,7 +69,7 @@ def _background_finalize_visualization(
try:
runner.finalize_visualization(surgery_id=surgery_id)
except Exception:
logger.exception("video batch background visualization failed surgery_id={}", surgery_id)
logger.exception("offline batch visualization failed surgery_id={}", surgery_id)
finally:
purge_expired_visualizations(
runner.root_dir,
@@ -56,7 +77,7 @@ def _background_finalize_visualization(
)
class VideoBatchSurgeryResponse(BaseModel):
class OfflineBatchResponse(BaseModel):
surgery_id: str
status: str
message: str
@@ -66,44 +87,29 @@ class VideoBatchSurgeryResponse(BaseModel):
doctor_display: str | None = None
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(
"/video-batch-surgery",
response_model=VideoBatchSurgeryResponse,
summary="非实时精确模式上传单路 MP4 并跑配置引用包 batch",
"/offline-batch",
response_model=OfflineBatchResponse,
summary="链路 3非实时精确模式上传 MP4 + 可选标注视频)",
description=(
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存上传视频,调用配置算法子进程包 main.py默认 algorithm_subprocesses/5.15"
"解析 TSV 后写入最终结果;可选 include_visualization 生成临时标注视频"
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。不启动 RTSP 实时会话、不触发语音终端;"
"调用 algorithm_subprocesses/5.15 main.py解析 TSV 后写入最终结果。"
),
)
async def video_batch_surgery(
async def offline_batch(
background_tasks: BackgroundTasks,
surgery_id: Annotated[str, Form()],
video1: Annotated[UploadFile, File(description="单路完整 MP4")],
candidate_consumables_json: Annotated[str, Form()] = "[]",
include_visualization: Annotated[bool, Form()] = False,
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
) -> SurgeryApiResponse:
) -> OfflineBatchResponse:
_require_demo_orchestrator()
if len(surgery_id) != 6 or not surgery_id.isdigit():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="surgery_id must be exactly 6 digits",
)
if not settings.demo_orchestrator_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
try:
candidates = json.loads(candidate_consumables_json)
except json.JSONDecodeError as exc:
@@ -125,9 +131,9 @@ async def video_batch_surgery(
detail="video1 is empty",
)
logger.info(
"video batch request surgery_id={} flow={} include_visualization={}",
"offline batch request surgery_id={} flow={} include_visualization={}",
surgery_id,
VIDEO_BATCH_FLOW_MARKER,
OFFLINE_BATCH_FLOW_MARKER,
include_visualization,
)
runner = VideoBatchRunner()
@@ -154,18 +160,17 @@ async def video_batch_surgery(
)
)
except (FileNotFoundError, RuntimeError, OSError, ValueError) as exc:
logger.exception("video batch failed surgery_id={}: {}", surgery_id, exc)
logger.exception("offline batch failed surgery_id={}: {}", surgery_id, exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"video batch failed: {exc}",
detail=f"offline batch failed: {exc}",
) from exc
await pipeline.save_video_batch_result(surgery_id, result.details)
logger.info(
"video batch result saved to database surgery_id={} rows={} (query GET /client/surgeries/{}/result now)",
"offline batch result saved surgery_id={} rows={}",
surgery_id,
len(result.details),
surgery_id,
)
cache_input = result.output_path.parent.parent / "input" / "input.mp4"
@@ -188,7 +193,7 @@ async def video_batch_surgery(
visualization_url: str | None = None
if include_visualization:
visualization_url = f"/internal/demo/video-batch-surgery/{surgery_id}/visualization"
visualization_url = f"/internal/demo/offline-batch/{surgery_id}/visualization"
doctor = result.doctor
doctor_suffix = ""
if doctor is not None and doctor.display:
@@ -196,7 +201,7 @@ async def video_batch_surgery(
vis_suffix = ""
if include_visualization:
vis_suffix = ";标注视频后台生成中(完成后刷新 visualization URL24 小时内有效)"
return VideoBatchSurgeryResponse(
return OfflineBatchResponse(
surgery_id=surgery_id,
status="accepted",
message=(
@@ -212,26 +217,22 @@ async def video_batch_surgery(
@router.get(
"/video-batch-surgery/{surgery_id}/visualization",
summary="获取非实时精确模式生成的带标签视频",
"/offline-batch/{surgery_id}/visualization",
summary="链路 3获取离线 batch 生成的标注视频",
)
async def video_batch_visualization(surgery_id: str) -> FileResponse:
async def offline_batch_visualization(surgery_id: str) -> FileResponse:
_require_demo_orchestrator()
if len(surgery_id) != 6 or not surgery_id.isdigit():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="surgery_id must be exactly 6 digits",
)
if not settings.demo_orchestrator_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
runner = VideoBatchRunner()
path = runner.latest_visualization_path(surgery_id)
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="video batch visualization not found; run non-realtime batch first.",
detail="offline batch visualization not found; run offline-batch first.",
)
return FileResponse(
path,
@@ -242,16 +243,15 @@ async def video_batch_visualization(surgery_id: str) -> FileResponse:
@router.post(
"/orchestrate-and-start",
"/simulated-start",
response_model=SurgeryApiResponse,
summary="一键联调:上传 14 路视频并开录",
summary="链路 2模拟实时上传 14 路视频并开录 + 语音)",
description=(
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存一路或多路视频、启动 MediaMTX+ffmpeg、"
"将 RTSP 映射合并写入 OR_SITE_CONFIG_JSON_FILE 的 video_rtsp_urls再执行与 /client/surgeries/start 相同的开录逻辑"
"(含按 voice_or_room_bindings 解析并 WebSocket 推送语音终端指派)。"
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。合成假 RTSP 并写入 OR_SITE_CONFIG_JSON_FILE"
"再执行与 POST /client/surgeries/start 相同的实时开录与语音终端指派。"
),
)
async def orchestrate_and_start(
async def simulated_start(
surgery_id: Annotated[str, Form()],
video1: Annotated[UploadFile, File(description="第 1 路视频(必填,至少一路)")],
video2: Annotated[UploadFile | None, File(description="第 2 路视频(可选)")] = None,
@@ -269,28 +269,14 @@ async def orchestrate_and_start(
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
voice_hub: VoiceTerminalHub = Depends(get_voice_terminal_hub),
) -> SurgeryApiResponse:
_require_demo_orchestrator()
json_path = _require_site_config_path()
logger.info(
"demo orchestrate-and-start: surgery_id={} cameras={} rpaths={}",
"simulated-start: surgery_id={} cameras={} rpaths={}",
surgery_id,
(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(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
path_raw = (settings.or_site_config_json_file or "").strip()
if not path_raw:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"OR_SITE_CONFIG_JSON_FILE must be set to a writable path "
"(strict site JSON with video_rtsp_urls + voice_or_room_bindings); "
"in Docker, bind-mount a host file to this path."
),
)
json_path = Path(path_raw).expanduser()
try:
candidates = json.loads(candidate_consumables_json)
@@ -304,56 +290,27 @@ async def orchestrate_and_start(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="candidate_consumables_json must be a JSON array",
)
candidates = normalize_candidate_consumables_raw(candidates)
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(),
uploads = await read_simulated_stream_uploads(
video1=video1,
video2=video2,
video3=video3,
video4=video4,
camera_1=camera_1,
camera_2=camera_2,
camera_3=camera_3,
camera_4=camera_4,
rtsp_path_1=rtsp_path_1,
rtsp_path_2=rtsp_path_2,
rtsp_path_3=rtsp_path_3,
rtsp_path_4=rtsp_path_4,
)
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=[g[2] for g in gathered],
camera_ids=[u.camera_id for u in uploads],
candidate_consumables=candidates,
)
except Exception as exc:
@@ -362,73 +319,28 @@ async def orchestrate_and_start(
detail=str(exc),
) from exc
work_root = Path(tempfile.mkdtemp(prefix="orm-orch-"))
try:
def _save_files() -> None:
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:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to save uploads: {exc}",
) from exc
streams = [
StreamSpec(
camera_id=g[2],
file_path=work_root / f"v{i + 1}{g[1]}",
rtsp_path=g[3],
await prepare_simulated_rtsp_streams(
site_config_json_path=json_path,
uploads=uploads,
)
for i, g in enumerate(gathered)
]
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)
except HTTPException:
raise
except Exception as 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}",
detail=f"simulated RTSP setup failed: {exc}",
) from exc
host_for_json = _orchestrate_write_rtsp_host()
try:
def _write() -> None:
merge_video_rtsp_urls_into_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),
return await accept_live_recording(
pipeline,
voice_hub,
surgery_id=body.surgery_id,
camera_ids=list(body.camera_ids),
candidate_consumables=list(body.candidate_consumables),
message="假 RTSP 已起;映射已写入;摄像头录制已开始。",
)
except SurgeryPipelineError as exc:
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
@@ -436,16 +348,3 @@ async def orchestrate_and_start(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": exc.code, "message": exc.message, "surgery_id": body.surgery_id},
) from exc
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,
)
return SurgeryApiResponse(
surgery_id=body.surgery_id,
status="accepted",
message="假 RTSP 已起;映射已写入;摄像头录制已开始。",
)