From ba8fd454eae5928b61f6d5ab9155c19621b89c0a Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 9 Jul 2026 04:42:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20Camera=20tab=20=E6=8E=A5?= =?UTF-8?q?=E7=9C=9F=20MJPEG=20stream=20+=20start/stop=20=E7=8B=80?= =?UTF-8?q?=E6=85=8B=E6=A9=9F=EF=BC=88=E5=89=8D=E7=AB=AF=E5=A1=8A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推論工作區前端塊1:Camera tab 從 placeholder 換成真正接 MJPEG stream 的即時畫面。照 edge-ai-platform POC camera-feed.tsx 移植(唯讀參考、 重寫為 visionA 慣例)。 - camera-feed.tsx:MJPEG + ResizeObserver + overlay slot(塊2 用)+ 角落標籤 - camera.ts buildStreamUrl:cache-bust 走 _t= query(encode)、token 絕不 放 URL(same-origin cookie 認證、對齊 use-websocket §10) - workspace-client Camera tab:接 CameraFeed + 串流狀態機、掉線清串流採 render 期衍生(非 effect setState) - use-websocket.ts:修 same-origin WS URL bug——getWsBaseUrl 回 "" 時 new WebSocket() 需絕對 URL,補 window.location wss/ws(塊2 WS 用) - types/camera.ts + types/inference.ts(後者鏡射 local-tool driver.InferenceResult) - start/stop 契約改打 /api/camera/start|stop - i18n 9 key 雙語齊全 Reviewer 0C/0M 通過。13 test(camera-feed 8 + camera 5)PASS、tsc/eslint clean。 (全套 11 failed 全在 conversion-store.test.ts 既有 flaky、本次未觸碰、經 grep 證實無關) follow-up(交塊2/3):掉線→回線 stream 卡舊 URL,建議掉線一併 clearStream。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/[deviceId]/workspace-client.tsx | 80 +++++++---- .../components/workspace/camera-feed.test.tsx | 117 ++++++++++++++++ .../src/components/workspace/camera-feed.tsx | 126 ++++++++++++++++++ visionA-frontend/src/hooks/use-websocket.ts | 8 ++ visionA-frontend/src/lib/camera.test.ts | 52 ++++++++ visionA-frontend/src/lib/camera.ts | 42 ++++++ .../src/lib/i18n/dictionaries/en.ts | 12 ++ .../src/lib/i18n/dictionaries/zh-Hant.ts | 10 ++ visionA-frontend/src/types/camera.ts | 51 +++++++ visionA-frontend/src/types/inference.ts | 64 +++++++++ 10 files changed, 538 insertions(+), 24 deletions(-) create mode 100644 visionA-frontend/src/components/workspace/camera-feed.test.tsx create mode 100644 visionA-frontend/src/components/workspace/camera-feed.tsx create mode 100644 visionA-frontend/src/lib/camera.test.ts create mode 100644 visionA-frontend/src/lib/camera.ts create mode 100644 visionA-frontend/src/types/camera.ts create mode 100644 visionA-frontend/src/types/inference.ts diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx index 0fc2395..1555df6 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -5,35 +5,38 @@ * * 對齊 pages.md §8.4、flow-offline-handling.md §6。 * - * F6 範圍(雛形 / 骨架): + * 塊 1 範圍(Camera MJPEG 串流顯示 + 開始/停止狀態機): * - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕 - * - Tabs:Camera / Image / Video / Batch(只有 Camera 做骨架,其他 stub 標示 Phase 1) - * - Camera tab 內:placeholder(F8 接 MJPEG stream + InferencePanel) - * - 裝置掉線(remoteStatus != online):顯示全頁遮罩 + 返回按鈕 + * - Tabs:Camera / Image / Video / Batch(Camera 已接真串流,其餘塊 3 補) + * - Camera tab 內:CameraFeed 顯示 MJPEG stream(start 成功後) + * - start/stop 契約修正:改打 `/api/camera/start` + `/api/camera/stop`(見評估 §3.1 R-C1) + * - 裝置掉線(remoteStatus != online):顯示全頁遮罩 + 返回按鈕;掉線時自動清串流 * - * F6 不做(Phase 1 / F8 補): - * - 真的 MJPEG stream 顯示(透過 tunnel 從 local agent 中繼) - * - InferencePanel 的 classification result / performance metrics - * - start/stop inference 的 WS 串流訂閱 + * 塊 1 不做(後續塊補): + * - InferencePanel 的 classification result / performance metrics(塊 2 接 WS) + * - canvas overlay 邊界框繪製(塊 2) + * - Image / Video / Batch tab 上傳與顯示(塊 3) * - Camera 來源選擇(SourceSelector) - * - Confidence slider / overlay 繪製 * * 重要:雲端版 Workspace 對「裝置離線」極敏感 — - * 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩。 + * 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。 */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { ArrowLeft, WifiOff } from "lucide-react"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; +import { CameraFeed } from "@/components/workspace/camera-feed"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api, ApiError } from "@/lib/api"; +import { buildStreamUrl } from "@/lib/camera"; import { useT } from "@/lib/i18n/context"; import { useDeviceStore } from "@/stores/device-store"; +import type { MediaUploadResponse } from "@/types/camera"; import { toast } from "sonner"; interface WorkspaceClientProps { @@ -47,21 +50,35 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { const fetchDevice = useDeviceStore((s) => s.fetchDevice); const [isRunning, setIsRunning] = useState(false); const [busy, setBusy] = useState(false); + // 塊 1:Camera MJPEG 串流 URL(start 成功後由後端回傳的 streamUrl 組出) + const [streamUrl, setStreamUrl] = useState(""); useEffect(() => { if (deviceId) void fetchDevice(deviceId); }, [deviceId, fetchDevice]); + /** 停止串流的共用清理(stop / unmount 都用)。 */ + const clearStream = useCallback(() => { + setStreamUrl(""); + setIsRunning(false); + }, []); + async function handleStart() { setBusy(true); try { - await api.post(`/api/devices/${encodeURIComponent(deviceId)}/inference/start`); + // 契約修正(評估 §3.1 R-C1):camera pipeline 入口為 POST /api/camera/start + // body 帶 deviceId;後端回傳 { streamUrl, sourceType }(camera_handler.go:119-125) + const data = await api.post("/api/camera/start", { + deviceId, + }); + const url = data?.streamUrl ?? "/api/camera/stream"; + // cache-bust:每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線 + setStreamUrl(buildStreamUrl(url, `${deviceId}-${Date.now()}`)); setIsRunning(true); } catch (err) { - // 雛形後端可能 501;不當致命錯誤 + // 後端尚未接上 proxy 時可能 501;不當致命錯誤,僅提示 if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") { - toast.info("雛形:start inference 尚未接後端"); - setIsRunning(true); + toast.info(t("workspace.camera.startNotWired")); } else { toast.error(err instanceof Error ? err.message : String(err)); } @@ -73,13 +90,13 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { async function handleStop() { setBusy(true); try { - await api.post(`/api/devices/${encodeURIComponent(deviceId)}/inference/stop`); + await api.post("/api/camera/stop", { deviceId }); } catch (err) { if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) { toast.error(err instanceof Error ? err.message : String(err)); } } finally { - setIsRunning(false); + clearStream(); setBusy(false); } } @@ -96,6 +113,9 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { // 裝置尚未載入(雛形:selectedDevice 可能為 null)— 簡易占位 const device = selectedDevice; const isOnline = device?.remoteStatus === "online"; + // 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 卡在已死的 MJPEG 連線)。 + // 用 render 期衍生而非 effect + setState,避免 cascading render。 + const effectiveStreamUrl = isOnline ? streamUrl : ""; return (
@@ -147,20 +167,32 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
- -

- {t("workspace.placeholder.cameraComingSoon")} -

+ + {effectiveStreamUrl ? ( + + ) : ( +
+

+ {t("workspace.camera.idleHint")} +

+
+ )}
- Inference + + {t("workspace.inference.panelTitle")} +

- {/* 雛形:InferencePanel stub。F8 補 ClassificationResult + PerformanceMetrics */} - — Phase 1 + {/* 塊 2 補:接 WS `inference:` → ClassificationResult + PerformanceMetrics + overlay */} + {t("workspace.inference.panelPending")}

diff --git a/visionA-frontend/src/components/workspace/camera-feed.test.tsx b/visionA-frontend/src/components/workspace/camera-feed.test.tsx new file mode 100644 index 0000000..a2ac7b8 --- /dev/null +++ b/visionA-frontend/src/components/workspace/camera-feed.test.tsx @@ -0,0 +1,117 @@ +/** + * CameraFeed 單元測試(塊 1) + * + * 驗證: + * - 無 streamUrl / batchImageUrl → 顯示 empty placeholder(含提示文字) + * - 有 streamUrl → render `` 指向該 URL + * - alt 文字依 sourceType 切換(camera / image / video) + * - 非 camera 來源 → 顯示角落來源標籤;camera 來源 → 不顯示 + * - batchImageUrl 優先於 streamUrl + * + * ResizeObserver 在 jsdom 無原生實作,於 setup 補 mock。 + */ +import { render, screen } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; + +import { CameraFeed } from "./camera-feed"; + +beforeAll(() => { + // jsdom 無 ResizeObserver — 補最小 mock,避免 useEffect 內 new 時 throw + if (!("ResizeObserver" in globalThis)) { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = + ResizeObserverMock; + } +}); + +function renderWithLocale(ui: React.ReactElement) { + // 預設 locale = zh-Hant + return render({ui}); +} + +describe("", () => { + it("無來源時顯示 empty placeholder", () => { + renderWithLocale(); + expect(screen.getByTestId("camera-feed-empty")).toBeInTheDocument(); + expect(screen.getByText("沒有輸入來源")).toBeInTheDocument(); + expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument(); + }); + + it("有 streamUrl 時 render 指向該 URL", () => { + renderWithLocale( + , + ); + const img = screen.getByTestId("camera-feed-img") as HTMLImageElement; + expect(img).toBeInTheDocument(); + expect(img.getAttribute("src")).toBe("/api/camera/stream?_t=abc"); + }); + + it("camera 來源使用「即時攝影機畫面」alt,且不顯示來源標籤", () => { + renderWithLocale(); + expect(screen.getByAltText("即時攝影機畫面")).toBeInTheDocument(); + expect( + screen.queryByTestId("camera-feed-source-label"), + ).not.toBeInTheDocument(); + }); + + it("image 來源使用「上傳的圖片」alt,且顯示來源標籤", () => { + renderWithLocale(); + expect(screen.getByAltText("上傳的圖片")).toBeInTheDocument(); + expect(screen.getByTestId("camera-feed-source-label")).toBeInTheDocument(); + }); + + it("video 來源使用「影片播放」alt", () => { + renderWithLocale(); + expect(screen.getByAltText("影片播放")).toBeInTheDocument(); + }); + + it("batchImageUrl 優先於 streamUrl", () => { + renderWithLocale( + , + ); + const img = screen.getByTestId("camera-feed-img") as HTMLImageElement; + expect(img.getAttribute("src")).toBe("/api/media/batch-images/2"); + }); + + it("render overlay slot 內容", () => { + renderWithLocale( + } + />, + ); + expect(screen.getByTestId("overlay-probe")).toBeInTheDocument(); + }); + + it("dimensions 變動時觸發 onDimensionsChange(透過 ResizeObserver)", () => { + // 用可控 mock 觀察 observe 被呼叫 + const observe = vi.fn(); + class RO { + observe = observe; + unobserve() {} + disconnect() {} + } + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = RO; + + const onDimensionsChange = vi.fn(); + renderWithLocale( + , + ); + expect(observe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/visionA-frontend/src/components/workspace/camera-feed.tsx b/visionA-frontend/src/components/workspace/camera-feed.tsx new file mode 100644 index 0000000..81039c8 --- /dev/null +++ b/visionA-frontend/src/components/workspace/camera-feed.tsx @@ -0,0 +1,126 @@ +"use client"; + +/** + * CameraFeed — MJPEG 串流顯示元件(塊 1) + * + * 移植自 POC 範本 edge-ai-platform/frontend/src/components/camera/camera-feed.tsx + * (唯讀參考、非搬 code;重寫為 visionA 慣例:useT / 設計 token / data-testid / a11y) + * + * 職責(塊 1): + * - 接後端 MJPEG stream(``,multipart/x-mixed-replace)顯示即時畫面 + * - 無來源時顯示 placeholder + * - 對外開放 `overlay` slot:塊 2 的 `` 邊界框會疊在這裡(absolute 定位) + * - `onDimensionsChange`:回報 `` 實際 render 尺寸給 overlay 換算 normalized bbox → px + * + * 認證:MJPEG 同 origin + 瀏覽器自動帶 `visiona_session` cookie(見 lib/camera.ts 註解), + * 不走 URL query token(與 POC 差異、對齊 visionA 安全決策)。 + * + * 塊 2 補:canvas overlay 內容(本元件已預留 `overlay` slot)。 + */ + +import { useEffect, useRef } from "react"; + +import type { SourceType } from "@/types/camera"; +import { useT } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +export interface CameraFeedProps { + /** 完整 MJPEG stream URL(由 buildStreamUrl 組出);空字串表示尚未開始 */ + streamUrl: string; + /** 顯示寬度(px);高度依原始比例自動 */ + width?: number; + /** 當前來源類型(決定 alt 文字與角落標籤) */ + sourceType?: SourceType | null; + /** batch 模式改用靜態單張圖 URL(塊 3 用);優先於 streamUrl */ + batchImageUrl?: string; + /** `` render 尺寸變動時回報(供 overlay 換算座標) */ + onDimensionsChange?: (width: number, height: number) => void; + /** 疊在畫面上的 overlay(塊 2 的 CameraOverlay canvas) */ + overlay?: React.ReactNode; +} + +export function CameraFeed({ + streamUrl, + width = 640, + sourceType, + batchImageUrl, + onDimensionsChange, + overlay, +}: CameraFeedProps) { + const t = useT(); + const imgRef = useRef(null); + + // 觀察 `` 實際 render 尺寸,回報給 overlay(POC 用 ResizeObserver 同法) + useEffect(() => { + const img = imgRef.current; + if (!img || !onDimensionsChange) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width: w, height: h } = entry.contentRect; + if (w > 0 && h > 0) { + onDimensionsChange(Math.round(w), Math.round(h)); + } + } + }); + observer.observe(img); + return () => observer.disconnect(); + }, [onDimensionsChange]); + + const displayUrl = batchImageUrl || streamUrl; + + if (!displayUrl) { + return ( +
+
+

{t("workspace.camera.noSource")}

+

{t("workspace.camera.noSourceHint")}

+
+
+ ); + } + + const altText = + sourceType === "image" || sourceType === "batch_image" + ? t("workspace.camera.altUploadedImage") + : sourceType === "video" + ? t("workspace.camera.altVideoPlayback") + : t("workspace.camera.altCameraFeed"); + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- MJPEG multipart 串流必須用原生 ,next/image 不支援 */} + {altText} + {overlay} + {sourceType && sourceType !== "camera" && ( +
+ {sourceType === "image" + ? t("workspace.tabs.image") + : sourceType === "batch_image" + ? t("workspace.tabs.batch") + : t("workspace.tabs.video")} +
+ )} +
+ ); +} diff --git a/visionA-frontend/src/hooks/use-websocket.ts b/visionA-frontend/src/hooks/use-websocket.ts index d54c31f..00ebe79 100644 --- a/visionA-frontend/src/hooks/use-websocket.ts +++ b/visionA-frontend/src/hooks/use-websocket.ts @@ -95,6 +95,14 @@ export function useWebSocket( // OF2:不再附加 token 到 querystring(auth-store 已無 token 欄位)。 // BFF 模式下若 WS 與 API 同 origin,瀏覽器會自動帶 visiona_session cookie; // 若跨 origin,待 OF7 / Phase 1 補上述 (a)/(b)/(c) 之一的安全認證機制。 + // + // getWsBaseUrl() 同 origin 時回 ""(見 lib/api.ts)。但 `new WebSocket()` 規格要求 + // 絕對 URL(含 ws://ws / wss:// scheme),相對路徑會直接 throw SyntaxError。 + // → 這裡把同 origin 情境用 window.location 補成絕對 wss/ws URL(http→ws / https→wss)。 + if (base === "" && typeof window !== "undefined") { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${window.location.host}${normalizedPath}`; + } return `${base}${normalizedPath}`; } diff --git a/visionA-frontend/src/lib/camera.test.ts b/visionA-frontend/src/lib/camera.test.ts new file mode 100644 index 0000000..49314a1 --- /dev/null +++ b/visionA-frontend/src/lib/camera.test.ts @@ -0,0 +1,52 @@ +/** + * lib/camera 單元測試(塊 1) + * + * 驗證 buildStreamUrl: + * - jsdom(window 存在)+ 無 NEXT_PUBLIC_API_BASE → base = ""(同 origin),回相對路徑 + * - cacheBust 以 `_t=` query 附加,且做 URL encode + * - 已含 query 的 URL 用 `&` 串接 + * - 相對路徑補開頭斜線 + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { buildStreamUrl, CAMERA_STREAM_PATH } from "./camera"; + +const ORIGINAL = process.env.NEXT_PUBLIC_API_BASE; + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.NEXT_PUBLIC_API_BASE; + else process.env.NEXT_PUBLIC_API_BASE = ORIGINAL; +}); + +describe("buildStreamUrl", () => { + it("同 origin(無 env)回相對路徑,不含 cacheBust 時無 query", () => { + delete process.env.NEXT_PUBLIC_API_BASE; + expect(buildStreamUrl(CAMERA_STREAM_PATH)).toBe("/api/camera/stream"); + }); + + it("附加 cacheBust 為 _t query 並 encode", () => { + delete process.env.NEXT_PUBLIC_API_BASE; + expect(buildStreamUrl("/api/camera/stream", "dev 1")).toBe( + "/api/camera/stream?_t=dev%201", + ); + }); + + it("已含 query 時用 & 串接 cacheBust", () => { + delete process.env.NEXT_PUBLIC_API_BASE; + expect(buildStreamUrl("/api/camera/stream?x=1", "abc")).toBe( + "/api/camera/stream?x=1&_t=abc", + ); + }); + + it("跨 origin(有 env)前綴 base URL", () => { + process.env.NEXT_PUBLIC_API_BASE = "https://api.example.com"; + expect(buildStreamUrl("/api/camera/stream")).toBe( + "https://api.example.com/api/camera/stream", + ); + }); + + it("相對路徑無開頭斜線時自動補上", () => { + delete process.env.NEXT_PUBLIC_API_BASE; + expect(buildStreamUrl("api/camera/stream")).toBe("/api/camera/stream"); + }); +}); diff --git a/visionA-frontend/src/lib/camera.ts b/visionA-frontend/src/lib/camera.ts new file mode 100644 index 0000000..7af3628 --- /dev/null +++ b/visionA-frontend/src/lib/camera.ts @@ -0,0 +1,42 @@ +/** + * Camera / Media 串流 URL 工具 — visionA Cloud 前端 + * + * 認證策略(與 POC 不同,重要): + * POC(edge-ai-platform)沒有使用者登入,MJPEG `` 靠 relay token 走 URL query + * (`?token=...`,見 relay-token-sync.tsx)。visionA 是正式產品線,採 OIDC BFF: + * - api-server 有 AuthMiddleware + `visiona_session` HttpOnly cookie + * - `/api/camera/stream` 在 AuthMiddleware 後 + * → 因此 visionA **不走 URL query token**(那會把憑證洩漏到 URL / log / referrer, + * 且與 use-websocket.ts §10 明令「禁止 querystring token」的安全決策衝突)。 + * → 改為:MJPEG `` 與 stream 一律**同 origin + 瀏覽器自動帶 cookie**。 + * + * 為什麼同 origin 可行: + * stage / prod 由 nginx 反代把 `/api/*` 與前端放同一 host(見 api.ts getApiBaseUrl 註解), + * `` 對同 origin 的 GET 會自動攜帶 `visiona_session` cookie,無需在 URL 帶憑證。 + * + * ⚠️ 介面契約假設(待與 backend 對齊): + * - api-server 需把 `/api/camera/stream` 宣告為 streaming proxy(見評估 §3.1 stubs.go:48) + * - 跨 origin dev(frontend :3000 ↔ backend :3721)情境下 `` 帶 cookie 需 backend CORS + * 允許 credentials;若 dev 無法同 origin,backend 需提供短期 stream ticket(後續對齊)。 + */ + +import { getApiBaseUrl } from "@/lib/api"; + +/** local-tool 端寫死的 MJPEG stream 路徑(camera_handler.go:118/218)。 */ +export const CAMERA_STREAM_PATH = "/api/camera/stream"; + +/** + * 由後端回傳的相對 streamUrl 組出瀏覽器可直接塞進 `` 的完整 URL。 + * + * @param streamUrl 後端回傳的相對路徑(通常為 `/api/camera/stream`) + * @param cacheBust 可選的 cache-busting 值(每次 start 換一個,避免瀏覽器重用舊的 + * 已結束 MJPEG 連線)。傳 deviceId + 時間戳即可。 + */ +export function buildStreamUrl(streamUrl: string, cacheBust?: string): string { + const base = getApiBaseUrl(); + const path = streamUrl.startsWith("/") ? streamUrl : `/${streamUrl}`; + const full = `${base}${path}`; + if (!cacheBust) return full; + const sep = full.includes("?") ? "&" : "?"; + return `${full}${sep}_t=${encodeURIComponent(cacheBust)}`; +} diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index de199bf..bc2f90e 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -278,6 +278,18 @@ export const en: Dictionary = { "workspace.header.title": "Workspace", "workspace.inference.start": "Start inference", "workspace.inference.stop": "Stop inference", + "workspace.inference.panelTitle": "Inference", + "workspace.inference.panelPending": + "Live results (labels, confidence, bounding boxes) will appear here once inference is running.", + "workspace.camera.idleHint": + "Press \"Start inference\" to open the camera and begin streaming.", + "workspace.camera.startNotWired": + "The backend camera proxy is not wired up yet.", + "workspace.camera.noSource": "No input source", + "workspace.camera.noSourceHint": "Start inference to see the live feed.", + "workspace.camera.altCameraFeed": "Live camera feed", + "workspace.camera.altUploadedImage": "Uploaded image", + "workspace.camera.altVideoPlayback": "Video playback", "workspace.placeholder.cameraComingSoon": "Camera inference preview — F8 will wire up the MJPEG stream through the tunnel from local agent.", "workspace.offline.title": "Device went offline", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index df55c6f..3d98753 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -270,6 +270,16 @@ export const zhHant: Dictionary = { "workspace.header.title": "工作區", "workspace.inference.start": "開始推論", "workspace.inference.stop": "停止推論", + "workspace.inference.panelTitle": "推論結果", + "workspace.inference.panelPending": + "開始推論後,即時結果(標籤、信心度、邊界框)會顯示在這裡。", + "workspace.camera.idleHint": "按「開始推論」開啟攝影機並開始串流。", + "workspace.camera.startNotWired": "後端攝影機 proxy 尚未接上。", + "workspace.camera.noSource": "沒有輸入來源", + "workspace.camera.noSourceHint": "開始推論即可看到即時畫面。", + "workspace.camera.altCameraFeed": "即時攝影機畫面", + "workspace.camera.altUploadedImage": "上傳的圖片", + "workspace.camera.altVideoPlayback": "影片播放", "workspace.placeholder.cameraComingSoon": "Camera 推論介面預覽 — F8 會接上 MJPEG stream(透過 tunnel 從 local agent 中繼)", "workspace.offline.title": "裝置已離線", diff --git a/visionA-frontend/src/types/camera.ts b/visionA-frontend/src/types/camera.ts new file mode 100644 index 0000000..9972aaf --- /dev/null +++ b/visionA-frontend/src/types/camera.ts @@ -0,0 +1,51 @@ +/** + * Camera / Media 推論來源型別 — visionA Cloud 前端 + * + * 對齊: + * - local-tool/server/internal/api/handlers/camera_handler.go + * StartPipeline(camera)/ UploadImage / UploadVideo / UploadBatchImages 的 response + * - POC 範本 edge-ai-platform/frontend/src/types/camera.ts(唯讀參考,非搬 code) + * + * 四種推論來源共用同一套顯示管線(MJPEG `` + WS 結果),詳見 + * .autoflow/04-architecture/camera-e2e-effort-estimate.md §0.1。 + */ + +/** 推論來源類型 — 與 local-tool `sourceType` response 欄位一致。 */ +export type SourceType = "camera" | "image" | "video" | "batch_image"; + +/** 攝影機資訊(`GET /api/camera/list` 回傳)。 */ +export interface CameraInfo { + id: string; + name: string; + index: number; + width: number; + height: number; +} + +/** + * Camera / Media 啟動後的顯示狀態。 + * + * `streamUrl` 一律為 `/api/camera/stream`(local-tool 端寫死,見 camera_handler.go:118/218)。 + * 前端透過 api base 前綴組成完整 MJPEG URL。 + */ +export interface StreamState { + /** 是否正在串流(Camera 開啟 / Media 已上傳並開始推論) */ + isStreaming: boolean; + /** MJPEG stream 相對路徑(後端回傳,通常為 /api/camera/stream) */ + streamUrl: string; + /** 當前來源類型 */ + sourceType: SourceType | null; +} + +/** + * Media 上傳 / Camera 啟動的共用 response data。 + * + * 對齊 camera_handler.go 各 handler 的 `data` 欄位(streamUrl 必有,其餘依來源)。 + */ +export interface MediaUploadResponse { + streamUrl: string; + sourceType: SourceType; + width?: number; + height?: number; + filename?: string; +} diff --git a/visionA-frontend/src/types/inference.ts b/visionA-frontend/src/types/inference.ts new file mode 100644 index 0000000..3bac13d --- /dev/null +++ b/visionA-frontend/src/types/inference.ts @@ -0,0 +1,64 @@ +/** + * 推論結果型別 — visionA Cloud 前端 + * + * 對齊 local-tool 端 `driver.InferenceResult`(source-of-truth): + * local-tool/server/internal/driver/interface.go:49-83 + * + * 資料流(見 .autoflow/04-architecture/camera-e2e-effort-estimate.md §0.1 / §8): + * local-tool pipeline → resultCh → wsHub.BroadcastToRoom("inference:", result) + * → local agent WS pipe → api-server WS forward → 瀏覽器 WS + * + * ⚠️ 這裡的 json key 命名(camelCase)必須與 Go struct 的 `json:"..."` tag 完全一致, + * 不可自行改名,否則 overlay / 面板讀不到欄位。 + * + * bbox 座標為 normalized(0~1),overlay 繪製時需乘上 rendered 尺寸還原為 px + * (對齊 POC camera-overlay.tsx:`det.bbox.x * width`)。 + */ + +/** 邊界框(normalized 0~1,相對於原始 frame 尺寸)。 */ +export interface BBox { + x: number; + y: number; + width: number; + height: number; +} + +/** 分類結果(classification task)。 */ +export interface ClassResult { + label: string; + confidence: number; +} + +/** 偵測結果(detection task,含邊界框)。 */ +export interface DetectionResult { + label: string; + confidence: number; + bbox: BBox; +} + +/** + * 單次推論結果 — WS `inference:` room 推的 payload。 + * + * 對應 Go `driver.InferenceResult`: + * - classifications / detections:依 taskType 擇一(omitempty) + * - imageIndex/totalImages/filename:僅 batch_image 模式出現 + * - frameIndex/totalFrames:僅 video 模式出現 + */ +export interface InferenceResult { + deviceId?: string; + modelId?: string; + taskType: string; + timestamp: number; + latencyMs: number; + classifications?: ClassResult[]; + detections?: DetectionResult[]; + + // Batch image 專屬(batch_image 模式) + imageIndex?: number; + totalImages?: number; + filename?: string; + + // Video 專屬(video 模式) + frameIndex?: number; + totalFrames?: number; +}