import { qs, classLabel, formatTime, enabledChannelNames } from "../modules/dom.js"; import { apiFetch } from "../modules/api.js"; import { showToast, setServiceStatus } from "../modules/ui.js"; import { loadDashboard } from "./dashboard.js"; const API_ENDPOINT = "/api/detect"; const frameCanvas = document.createElement("canvas"); let videoUrl = null; let sessionId = null; let detectionActive = false; let requestInFlight = false; let lastDetectionAt = 0; function setInspectionStatus(text, className) { const status = qs("#inspectionStatus"); status.textContent = text; status.className = className; } function clearDetectionResults() { qs("#smokeCount").textContent = "0"; qs("#fireCount").textContent = "0"; qs("#maxConfidence").textContent = "--"; qs("#riskStatus").textContent = "待机"; qs("#detectionList").innerHTML = '
暂无检测结果
'; const context = qs("#overlayCanvas").getContext("2d"); context.clearRect(0, 0, qs("#overlayCanvas").width, qs("#overlayCanvas").height); } function drawDetections(detections = []) { const video = qs("#videoPreview"); if (!video.videoWidth || !video.videoHeight) return; const canvas = qs("#overlayCanvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const context = canvas.getContext("2d"); context.clearRect(0, 0, canvas.width, canvas.height); detections.forEach((detection) => { const [x1, y1, x2, y2] = detection.box || []; const color = detection.class === "fire" ? "#ff665e" : "#41d6c3"; context.strokeStyle = color; context.lineWidth = Math.max(2, canvas.width / 320); context.strokeRect(x1, y1, x2 - x1, y2 - y1); context.fillStyle = color; context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`; context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6)); }); } function updateDetectionResults(result) { const detections = result.detections || []; const smoke = detections.filter((item) => item.class === "smoke").length; const fire = detections.filter((item) => item.class === "fire").length; const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0); qs("#smokeCount").textContent = String(smoke); qs("#fireCount").textContent = String(fire); qs("#maxConfidence").textContent = max ? `${Math.round(max * 100)}%` : "--"; qs("#riskStatus").textContent = fire ? "高风险" : smoke ? "需关注" : "正常"; qs("#detectionList").innerHTML = detections.length ? detections.map((item) => `未发现烟雾或火焰目标
'; const alert = result.alert || {}; const channelNames = enabledChannelNames(alert.notification_channels); if (alert.triggered) { const labels = alert.classes.map(classLabel).join("、"); const alertStatus = qs("#alertStatus"); alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`; alertStatus.className = "alert-status alert-triggered"; showToast(`检测到${labels},已生成告警事件`, "error"); loadDashboard(); } else { const fireFrames = alert.consecutive?.fire || 0; const smokeFrames = alert.consecutive?.smoke || 0; const alertStatus = qs("#alertStatus"); alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`; alertStatus.className = "alert-status"; } drawDetections(detections); qs("#lastUpdated").textContent = `推理 ${result.inference_ms ?? "--"} ms`; } function captureFrame() { const video = qs("#videoPreview"); frameCanvas.width = video.videoWidth; frameCanvas.height = video.videoHeight; frameCanvas.getContext("2d").drawImage(video, 0, 0); return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88)); } async function detectCurrentFrame(timestamp) { const video = qs("#videoPreview"); if (!detectionActive || requestInFlight || !sessionId || video.paused || video.ended) return; const interval = Number(qs("#intervalSelect").value); if (timestamp - lastDetectionAt < interval) return; lastDetectionAt = timestamp; requestInFlight = true; qs("#loadingState").hidden = false; try { const frame = await captureFrame(); if (!frame) throw new Error("无法截取视频帧"); const form = new FormData(); form.append("file", frame, "video-frame.jpg"); const result = await apiFetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form }); updateDetectionResults(result); setInspectionStatus("检测运行中", "status-chip status-online"); setServiceStatus(true); } catch (error) { setInspectionStatus("检测异常", "status-chip status-offline"); showToast(`视频检测失败:${error.message}`, "error"); } finally { requestInFlight = false; qs("#loadingState").hidden = true; } } function scheduleDetection(timestamp) { detectCurrentFrame(timestamp); if (detectionActive) requestAnimationFrame(scheduleDetection); } async function startDetection() { const video = qs("#videoPreview"); if (!video.src) return; detectionActive = true; lastDetectionAt = -Infinity; qs("#runDetectionButton").textContent = "停止检测"; qs("#intervalSelect").disabled = true; try { await video.play(); requestAnimationFrame(scheduleDetection); } catch { stopDetection(); showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error"); } } function stopDetection() { detectionActive = false; qs("#runDetectionButton").textContent = "开始连续检测"; qs("#intervalSelect").disabled = false; const video = qs("#videoPreview"); setInspectionStatus(video.src ? "检测已暂停" : "等待视频", "status-chip status-idle"); } export async function resetSession() { const previous = sessionId; sessionId = crypto.randomUUID(); if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {}); } export function initInspection() { const video = qs("#videoPreview"); qs("#runDetectionButton").addEventListener("click", () => detectionActive ? stopDetection() : startDetection()); video.addEventListener("ended", stopDetection); video.addEventListener("timeupdate", () => { qs("#videoClock").textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`; }); video.addEventListener("seeked", async () => { drawDetections([]); await resetSession(); }); video.addEventListener("resize", () => drawDetections([])); qs("#videoInput").addEventListener("change", async () => { const [file] = qs("#videoInput").files; if (!file) return; stopDetection(); video.pause(); if (videoUrl) URL.revokeObjectURL(videoUrl); videoUrl = URL.createObjectURL(file); video.src = videoUrl; video.hidden = false; qs("#emptyState").hidden = true; qs("#sourceLabel").textContent = file.name; qs("#videoMeta").textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`; qs("#runDetectionButton").disabled = false; setInspectionStatus("视频已就绪", "status-chip status-online"); await resetSession(); clearDetectionResults(); }); qs("#clearButton").addEventListener("click", async () => { stopDetection(); video.pause(); video.removeAttribute("src"); video.load(); video.hidden = true; if (videoUrl) URL.revokeObjectURL(videoUrl); videoUrl = null; qs("#videoInput").value = ""; qs("#sourceLabel").textContent = "未选择视频"; qs("#videoMeta").textContent = "支持 MP4、WebM 等浏览器可播放格式"; qs("#emptyState").hidden = false; qs("#runDetectionButton").disabled = true; await resetSession(); clearDetectionResults(); }); }