update cam id
This commit is contained in:
@@ -141,10 +141,21 @@ async def health() -> HealthResponse | JSONResponse:
|
||||
summary="Demo 离线 batch(链路 3)是否可用",
|
||||
description="供 demo 页探测;始终注册,不依赖 DEMO_ORCHESTRATOR_ENABLED。",
|
||||
)
|
||||
async def recording_modes_status() -> dict:
|
||||
async def recording_modes_status(
|
||||
surgery_id: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
min_length=6,
|
||||
max_length=6,
|
||||
pattern=r"^\d{6}$",
|
||||
description="可选:查询该手术号是否仍有链路 1 实时开录占用。",
|
||||
),
|
||||
] = None,
|
||||
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)] = ...,
|
||||
) -> dict:
|
||||
f = (settings.or_site_config_json_file or "").strip()
|
||||
enabled = bool(settings.demo_orchestrator_enabled)
|
||||
return {
|
||||
body: dict = {
|
||||
"demo_recording_modes_enabled": enabled,
|
||||
"orchestrator_enabled": enabled,
|
||||
"offline_batch_method": "POST",
|
||||
@@ -167,6 +178,12 @@ async def recording_modes_status() -> dict:
|
||||
"rtsp_segment_ttl_hours": float(settings.rtsp_segment_ttl_hours),
|
||||
"video_batch_vis_ttl_hours": float(bp.VIDEO_BATCH_VIS_TTL_HOURS),
|
||||
}
|
||||
if surgery_id is not None:
|
||||
phase = pipeline.live_recording_phase(surgery_id)
|
||||
body["surgery_id"] = surgery_id
|
||||
body["live_recording_phase"] = phase
|
||||
body["live_recording_active"] = phase is not None
|
||||
return body
|
||||
|
||||
|
||||
class HlsPreviewEnsureRequest(BaseModel):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"video_rtsp_urls": {
|
||||
"or-cam-01": "rtsp://admin:Aa183137@192.168.3.2:554/Streaming/Channels/101",
|
||||
"or-cam-02": "rtsp://admin:Aa183137@192.168.3.3:554/Streaming/Channels/101",
|
||||
"or-cam-03": "rtsp://admin:Aa183137@192.168.3.4:554/Streaming/Channels/101",
|
||||
"or-cam-03": "rtsp://admin:Aa183137@192.168.3.3:554/Streaming/Channels/101",
|
||||
"or-cam-02": "rtsp://admin:Aa183137@192.168.3.4:554/Streaming/Channels/101",
|
||||
"or-cam-04": "rtsp://admin:Aa183137@192.168.3.5:554/Streaming/Channels/101"
|
||||
},
|
||||
"voice_or_room_bindings": [
|
||||
|
||||
@@ -148,6 +148,21 @@ async def offline_batch(
|
||||
)
|
||||
candidates = normalize_candidate_consumables_raw(candidates)
|
||||
|
||||
live_phase = pipeline.live_recording_phase(surgery_id)
|
||||
if live_phase is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "LIVE_RECORDING_ACTIVE",
|
||||
"message": (
|
||||
"该手术号仍有链路 1 实时开录(RTSP 切片)进行中,与离线精确互斥。"
|
||||
"请先 POST /client/surgeries/end 结束实时会话,或更换 surgery_id 后再上传。"
|
||||
),
|
||||
"surgery_id": surgery_id,
|
||||
"live_recording_phase": live_phase,
|
||||
},
|
||||
)
|
||||
|
||||
raw = await video1.read()
|
||||
if not raw:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
@@ -84,6 +85,10 @@ class SurgeryPipeline:
|
||||
except SurgeryPipelineError:
|
||||
raise
|
||||
|
||||
def live_recording_phase(self, surgery_id: str) -> Literal["starting", "recording"] | None:
|
||||
"""链路 1 实时会话是否占用该 surgery_id(starting=开录中,recording=已开录)。"""
|
||||
return self._sessions.active_recording_phase(surgery_id)
|
||||
|
||||
async def save_video_batch_result(
|
||||
self,
|
||||
surgery_id: str,
|
||||
|
||||
@@ -626,6 +626,10 @@ def test_demo_video_batch_endpoint_writes_queryable_result(
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[str, list[SurgeryConsumptionStored]] = {}
|
||||
|
||||
def live_recording_phase(self, surgery_id: str) -> None:
|
||||
del surgery_id
|
||||
return None
|
||||
|
||||
async def save_video_batch_result(
|
||||
self,
|
||||
surgery_id: str,
|
||||
@@ -729,6 +733,10 @@ def test_demo_video_batch_endpoint_stages_vis_and_purges_cache_when_requested(
|
||||
vis_calls.append((surgery_id, video_path, result_path))
|
||||
|
||||
class _FakePipeline:
|
||||
def live_recording_phase(self, surgery_id: str) -> None:
|
||||
del surgery_id
|
||||
return None
|
||||
|
||||
async def save_video_batch_result(
|
||||
self,
|
||||
surgery_id: str,
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
"""GET /internal/demo/recording-modes-status 契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import router as api_router
|
||||
from app.dependencies import get_surgery_pipeline
|
||||
from app.routers import recording_demo
|
||||
|
||||
|
||||
class _FakePipeline:
|
||||
def __init__(self, *, phase: Literal["starting", "recording"] | None = None) -> None:
|
||||
self._phase = phase
|
||||
|
||||
def live_recording_phase(self, surgery_id: str) -> Literal["starting", "recording"] | None:
|
||||
del surgery_id
|
||||
return self._phase
|
||||
|
||||
|
||||
def test_recording_modes_status_paths() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(api_router)
|
||||
app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline()
|
||||
client = TestClient(app)
|
||||
res = client.get("/internal/demo/recording-modes-status")
|
||||
assert res.status_code == 200
|
||||
@@ -19,3 +35,34 @@ def test_recording_modes_status_paths() -> None:
|
||||
assert body["rtsp_segment_ttl_hours"] == 24.0
|
||||
assert body["rtsp_segment_duration_sec"] == 120.0
|
||||
assert body["video_batch_vis_ttl_hours"] == 24.0
|
||||
assert "live_recording_active" not in body
|
||||
|
||||
|
||||
def test_recording_modes_status_with_surgery_id() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(api_router)
|
||||
app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline(phase="recording")
|
||||
client = TestClient(app)
|
||||
res = client.get("/internal/demo/recording-modes-status?surgery_id=100101")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["surgery_id"] == "100101"
|
||||
assert body["live_recording_phase"] == "recording"
|
||||
assert body["live_recording_active"] is True
|
||||
|
||||
|
||||
def test_offline_batch_rejects_active_live_session(monkeypatch) -> None:
|
||||
monkeypatch.setattr(recording_demo.settings, "demo_orchestrator_enabled", True)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(recording_demo.router)
|
||||
app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline(phase="recording")
|
||||
|
||||
client = TestClient(app)
|
||||
res = client.post(
|
||||
"/internal/demo/offline-batch",
|
||||
data={"surgery_id": "100101", "candidate_consumables_json": "[]"},
|
||||
files={"video1": ("case.mp4", b"video-bytes", "video/mp4")},
|
||||
)
|
||||
assert res.status_code == 409, res.text
|
||||
assert res.json()["detail"]["code"] == "LIVE_RECORDING_ACTIVE"
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
## 界面说明
|
||||
|
||||
- **链路 1**:只需填 `camera_ids` 与耗材候选,点「开始手术」;**无需上传视频**(服务端自动 RTSP 拉流切片)。
|
||||
- **链路 1 / 3 共用「步骤 2 · 耗材候选」**:标签区显示「编号 + 名称」;提交时以 `{"消耗品编号","名称"}` 对象数组发送(与医院导出表一致)。
|
||||
- 误把 `.mp4` 路径填进耗材候选时,页面会提示并阻止提交。
|
||||
- **链路 3**:独立 MP4 上传区;与链路 1 **互斥**——同一手术号若仍有实时开录,离线上传会被拒绝;页面会提示先「结束手术」或换号。
|
||||
- **模式记忆**:上次选择的链路会保存在浏览器 localStorage;默认首次打开为链路 3。
|
||||
|
||||
详见 [`docs/video-backends.md`](../../docs/video-backends.md)。
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
let videoVisToken = 0;
|
||||
const hlsPlayers = {};
|
||||
let serverRecordingConfig = null;
|
||||
let liveRecordingConflict = null;
|
||||
|
||||
const LS_RUN_MODE = "orm_demo_run_mode";
|
||||
const VALID_RUN_MODES = new Set(["live-rtsp", "offline-batch"]);
|
||||
|
||||
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
|
||||
const surgeryId = () => $("surgery-id").value.trim();
|
||||
@@ -51,6 +55,76 @@
|
||||
return activeMode;
|
||||
}
|
||||
|
||||
function loadSavedRunMode() {
|
||||
try {
|
||||
const saved = localStorage.getItem(LS_RUN_MODE);
|
||||
if (saved && VALID_RUN_MODES.has(saved)) return saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "offline-batch";
|
||||
}
|
||||
|
||||
function persistRunMode(mode) {
|
||||
try {
|
||||
localStorage.setItem(LS_RUN_MODE, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function liveRecordingConflictMessage(body) {
|
||||
const phase = body?.live_recording_phase;
|
||||
const phaseText = phase === "starting" ? "开录启动中" : phase === "recording" ? "录制中" : "进行中";
|
||||
return (
|
||||
`手术号 ${body?.surgery_id || surgeryId()} 仍有链路 1 实时会话(${phaseText},会 RTSP 切片)。` +
|
||||
"请先点「结束手术」或更换手术号,再跑离线精确。"
|
||||
);
|
||||
}
|
||||
|
||||
function updateLiveRecordingConflictBanner() {
|
||||
const el = $("live-recording-conflict");
|
||||
if (!el) return;
|
||||
if (activeMode !== "offline-batch" || !liveRecordingConflict?.live_recording_active) {
|
||||
el.textContent = "";
|
||||
el.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
el.textContent = liveRecordingConflictMessage(liveRecordingConflict);
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function refreshLiveRecordingConflict(sid) {
|
||||
if (!sid || !/^\d{6}$/.test(sid)) {
|
||||
liveRecordingConflict = null;
|
||||
updateLiveRecordingConflictBanner();
|
||||
return null;
|
||||
}
|
||||
const url = baseUrl() + `/internal/demo/recording-modes-status?surgery_id=${encodeURIComponent(sid)}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
addLog("GET", url, res.status, data, { error: !res.ok });
|
||||
if (res.ok) {
|
||||
liveRecordingConflict = data;
|
||||
updateLiveRecordingConflictBanner();
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
addLog("GET", url, "NETWORK", String(e), { error: true });
|
||||
}
|
||||
liveRecordingConflict = null;
|
||||
updateLiveRecordingConflictBanner();
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureOfflineNotBlockedByLiveSession(sid) {
|
||||
const status = await refreshLiveRecordingConflict(sid);
|
||||
if (status?.live_recording_active) {
|
||||
throw new Error(liveRecordingConflictMessage(status));
|
||||
}
|
||||
}
|
||||
|
||||
function showBanner(msg, type) {
|
||||
const el = $("banner");
|
||||
if (!el) return;
|
||||
@@ -536,7 +610,9 @@
|
||||
}
|
||||
|
||||
function setActiveMode(mode) {
|
||||
if (!VALID_RUN_MODES.has(mode)) mode = "offline-batch";
|
||||
activeMode = mode;
|
||||
persistRunMode(mode);
|
||||
document.querySelectorAll(".mode-card").forEach((card) => {
|
||||
card.classList.toggle("active", card.dataset.mode === mode);
|
||||
});
|
||||
@@ -555,7 +631,11 @@
|
||||
$("pill-mode").textContent =
|
||||
mode === "live-rtsp" ? "链路 1 · 真摄像头" : "链路 3 · 离线精确";
|
||||
refreshModeHints();
|
||||
if (mode !== "offline-batch") hideVideoBatchVisualization();
|
||||
if (mode !== "offline-batch") {
|
||||
hideVideoBatchVisualization();
|
||||
liveRecordingConflict = null;
|
||||
updateLiveRecordingConflictBanner();
|
||||
}
|
||||
refreshRecordingModesStatus();
|
||||
if (mode === "live-rtsp") {
|
||||
rebuildPreviewGrid();
|
||||
@@ -770,13 +850,22 @@
|
||||
}
|
||||
|
||||
async function refreshRecordingModesStatus() {
|
||||
const url = baseUrl() + "/internal/demo/recording-modes-status";
|
||||
const sid = surgeryId();
|
||||
const query =
|
||||
activeMode === "offline-batch" && /^\d{6}$/.test(sid)
|
||||
? `?surgery_id=${encodeURIComponent(sid)}`
|
||||
: "";
|
||||
const url = baseUrl() + "/internal/demo/recording-modes-status" + query;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
addLog("GET", url, res.status, data, { error: !res.ok });
|
||||
if (res.ok) {
|
||||
applyRecordingConfigHints(data);
|
||||
if (data.surgery_id && Object.prototype.hasOwnProperty.call(data, "live_recording_active")) {
|
||||
liveRecordingConflict = data;
|
||||
updateLiveRecordingConflictBanner();
|
||||
}
|
||||
}
|
||||
updateDemoModesPill(data, res.ok);
|
||||
} catch (e) {
|
||||
@@ -1054,6 +1143,12 @@
|
||||
showBanner("请先选择完整 MP4 文件", "err");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureOfflineNotBlockedByLiveSession(sid);
|
||||
} catch (e) {
|
||||
showBanner(e.message || String(e), "err");
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("surgery_id", sid);
|
||||
fd.append("video1", f, f.name);
|
||||
@@ -1064,7 +1159,14 @@
|
||||
);
|
||||
const { res, body } = await apiOfflineBatch(fd);
|
||||
if (!res.ok) {
|
||||
showBanner("离线处理失败:" + formatDetail(body), "err");
|
||||
const liveConflict = body?.detail?.code === "LIVE_RECORDING_ACTIVE";
|
||||
showBanner(
|
||||
liveConflict
|
||||
? liveRecordingConflictMessage(body.detail)
|
||||
: "离线处理失败:" + formatDetail(body),
|
||||
"err",
|
||||
);
|
||||
if (liveConflict) void refreshLiveRecordingConflict(sid);
|
||||
return;
|
||||
}
|
||||
lastVideoBatchDoctorDisplay = body?.doctor_display || "";
|
||||
@@ -1194,7 +1296,7 @@
|
||||
document.querySelectorAll(".mode-card").forEach((card) => {
|
||||
card.addEventListener("click", () => setActiveMode(card.dataset.mode));
|
||||
});
|
||||
setActiveMode("live-rtsp");
|
||||
setActiveMode(loadSavedRunMode());
|
||||
}
|
||||
|
||||
function initOfflineUpload() {
|
||||
@@ -1256,6 +1358,12 @@
|
||||
if (logEl) logEl.innerHTML = "";
|
||||
});
|
||||
$("base-url")?.addEventListener("change", refreshRecordingModesStatus);
|
||||
$("surgery-id")?.addEventListener("change", () => {
|
||||
if (activeMode === "offline-batch") void refreshLiveRecordingConflict(surgeryId());
|
||||
});
|
||||
$("surgery-id")?.addEventListener("blur", () => {
|
||||
if (activeMode === "offline-batch") void refreshLiveRecordingConflict(surgeryId());
|
||||
});
|
||||
$("camera-ids")?.addEventListener("input", () => {
|
||||
if (activeMode === "live-rtsp") startPreviewPolling();
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<section class="card">
|
||||
<h2>选择运行模式</h2>
|
||||
<div class="mode-cards mode-cards--two">
|
||||
<button type="button" class="mode-card active" data-mode="live-rtsp">
|
||||
<button type="button" class="mode-card" data-mode="live-rtsp">
|
||||
<div class="title">链路 1 · 真摄像头</div>
|
||||
<div class="desc">填 camera_id 开录 · 服务端自动拉 RTSP · 无需上传视频</div>
|
||||
</button>
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card mode-panel active" data-mode="live-rtsp">
|
||||
<section class="card mode-panel" data-mode="live-rtsp">
|
||||
<div class="mode-callout mode-callout--live">
|
||||
<strong>正式对接链路。</strong>客户端只传 <code>camera_ids</code> 与耗材候选;视频由服务端从 RTSP 拉流并切片,<em>不要</em>在此页上传 MP4。
|
||||
</div>
|
||||
@@ -73,6 +73,7 @@
|
||||
</div>
|
||||
<h2>步骤 1 · 上传 MP4</h2>
|
||||
<p class="labels-meta">不启动实时会话,处理完成后直接查结果。</p>
|
||||
<div id="live-recording-conflict" class="consumables-warn hidden" role="alert"></div>
|
||||
<div class="upload-zone" id="offline-zone" style="margin-top:12px">
|
||||
<div class="icon">MP4</div>
|
||||
<div id="offline-fname">点击或拖放 MP4 到此处</div>
|
||||
@@ -103,7 +104,7 @@
|
||||
<div id="rtsp-preview-grid" class="preview-grid preview-grid--rtsp"></div>
|
||||
</section>
|
||||
|
||||
<section class="card mode-panel active" data-mode="live-rtsp offline-batch">
|
||||
<section class="card mode-panel" data-mode="live-rtsp offline-batch">
|
||||
<h2>步骤 2 · 耗材候选(AI 白名单)</h2>
|
||||
<p id="consumables-mode-hint" class="field-hint">
|
||||
选择本次手术可能用到的耗材名称或产品编码;留空表示使用全部标签。<strong>此处不是视频上传区,请勿填写 .mp4 路径。</strong>
|
||||
|
||||
Reference in New Issue
Block a user