feat: 语音确认、联调与运维增强
- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining; 百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。 - Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。 - 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。 - 注意:.env 仍被 gitignore,本地密钥不会进入本提交。 Made-with: Cursor
This commit is contained in:
1
app/routers/__init__.py
Normal file
1
app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Optional API routers."""
|
||||
189
app/routers/demo_orch.py
Normal file
189
app/routers/demo_orch.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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 已起;映射已写入;摄像头录制已开始。",
|
||||
)
|
||||
Reference in New Issue
Block a user