feat(workspace): Camera tab 接真 MJPEG stream + start/stop 狀態機(前端塊1)

推論工作區前端塊1:Camera tab 從 placeholder 換成真正接 MJPEG stream
的即時畫面。照 edge-ai-platform POC camera-feed.tsx 移植(唯讀參考、
重寫為 visionA 慣例)。

- camera-feed.tsx:MJPEG <img> + 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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-09 04:42:41 +08:00
parent adab000987
commit ba8fd454ea
10 changed files with 538 additions and 24 deletions

View File

@ -5,35 +5,38 @@
* *
* pages.md §8.4flow-offline-handling.md §6 * pages.md §8.4flow-offline-handling.md §6
* *
* F6 / * 1 Camera MJPEG + /
* - + + RemoteDeviceBadge + / * - + + RemoteDeviceBadge + /
* - TabsCamera / Image / Video / Batch Camera stub Phase 1 * - TabsCamera / Image / Video / BatchCamera 3
* - Camera tab placeholderF8 MJPEG stream + InferencePanel * - Camera tab CameraFeed MJPEG streamstart
* - remoteStatus != online + * - start/stop `/api/camera/start` + `/api/camera/stop` §3.1 R-C1
* - remoteStatus != online +
* *
* F6 Phase 1 / F8 * 1
* - MJPEG stream tunnel local agent * - InferencePanel classification result / performance metrics 2 WS
* - InferencePanel classification result / performance metrics * - canvas overlay 2
* - start/stop inference WS * - Image / Video / Batch tab 3
* - Camera SourceSelector * - Camera SourceSelector
* - Confidence slider / overlay
* *
* Workspace * Workspace
* remoteStatus != online offline * remoteStatus != online offline
*/ */
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, WifiOff } from "lucide-react"; import { ArrowLeft, WifiOff } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { CameraFeed } from "@/components/workspace/camera-feed";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api, ApiError } from "@/lib/api"; import { api, ApiError } from "@/lib/api";
import { buildStreamUrl } from "@/lib/camera";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store"; import { useDeviceStore } from "@/stores/device-store";
import type { MediaUploadResponse } from "@/types/camera";
import { toast } from "sonner"; import { toast } from "sonner";
interface WorkspaceClientProps { interface WorkspaceClientProps {
@ -47,21 +50,35 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
const fetchDevice = useDeviceStore((s) => s.fetchDevice); const fetchDevice = useDeviceStore((s) => s.fetchDevice);
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// 塊 1Camera MJPEG 串流 URLstart 成功後由後端回傳的 streamUrl 組出)
const [streamUrl, setStreamUrl] = useState("");
useEffect(() => { useEffect(() => {
if (deviceId) void fetchDevice(deviceId); if (deviceId) void fetchDevice(deviceId);
}, [deviceId, fetchDevice]); }, [deviceId, fetchDevice]);
/** 停止串流的共用清理stop / unmount 都用)。 */
const clearStream = useCallback(() => {
setStreamUrl("");
setIsRunning(false);
}, []);
async function handleStart() { async function handleStart() {
setBusy(true); setBusy(true);
try { try {
await api.post(`/api/devices/${encodeURIComponent(deviceId)}/inference/start`); // 契約修正(評估 §3.1 R-C1camera pipeline 入口為 POST /api/camera/start
// body 帶 deviceId後端回傳 { streamUrl, sourceType }camera_handler.go:119-125
const data = await api.post<MediaUploadResponse>("/api/camera/start", {
deviceId,
});
const url = data?.streamUrl ?? "/api/camera/stream";
// cache-bust每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線
setStreamUrl(buildStreamUrl(url, `${deviceId}-${Date.now()}`));
setIsRunning(true); setIsRunning(true);
} catch (err) { } catch (err) {
// 雛形後端可能 501不當致命錯誤 // 後端尚未接上 proxy 時可能 501不當致命錯誤,僅提示
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") { if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
toast.info("雛形start inference 尚未接後端"); toast.info(t("workspace.camera.startNotWired"));
setIsRunning(true);
} else { } else {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
@ -73,13 +90,13 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
async function handleStop() { async function handleStop() {
setBusy(true); setBusy(true);
try { try {
await api.post(`/api/devices/${encodeURIComponent(deviceId)}/inference/stop`); await api.post("/api/camera/stop", { deviceId });
} catch (err) { } catch (err) {
if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) { if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
} finally { } finally {
setIsRunning(false); clearStream();
setBusy(false); setBusy(false);
} }
} }
@ -96,6 +113,9 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
// 裝置尚未載入雛形selectedDevice 可能為 null— 簡易占位 // 裝置尚未載入雛形selectedDevice 可能為 null— 簡易占位
const device = selectedDevice; const device = selectedDevice;
const isOnline = device?.remoteStatus === "online"; const isOnline = device?.remoteStatus === "online";
// 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 <img> 卡在已死的 MJPEG 連線)。
// 用 render 期衍生而非 effect + setState避免 cascading render。
const effectiveStreamUrl = isOnline ? streamUrl : "";
return ( return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8"> <div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
@ -147,20 +167,32 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
<TabsContent value="camera" className="space-y-4"> <TabsContent value="camera" className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
<Card className="min-h-[60vh]"> <Card className="min-h-[60vh]">
<CardContent className="bg-muted/40 grid h-full min-h-[60vh] place-items-center p-6 text-center"> <CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
<p className="text-muted-foreground text-sm"> {effectiveStreamUrl ? (
{t("workspace.placeholder.cameraComingSoon")} <CameraFeed
</p> streamUrl={effectiveStreamUrl}
sourceType="camera"
width={640}
/>
) : (
<div className="text-center">
<p className="text-muted-foreground text-sm">
{t("workspace.camera.idleHint")}
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card className="min-h-[60vh]"> <Card className="min-h-[60vh]">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Inference</CardTitle> <CardTitle className="text-base">
{t("workspace.inference.panelTitle")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{/* 雛形InferencePanel stub。F8 補 ClassificationResult + PerformanceMetrics */} {/* 塊 2 補:接 WS `inference:<deviceId>` → ClassificationResult + PerformanceMetrics + overlay */}
Phase 1 {t("workspace.inference.panelPending")}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -0,0 +1,117 @@
/**
* CameraFeed 1
*
*
* - streamUrl / batchImageUrl empty placeholder
* - streamUrl render `<img src>` 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(<LocaleProvider>{ui}</LocaleProvider>);
}
describe("<CameraFeed />", () => {
it("無來源時顯示 empty placeholder", () => {
renderWithLocale(<CameraFeed streamUrl="" />);
expect(screen.getByTestId("camera-feed-empty")).toBeInTheDocument();
expect(screen.getByText("沒有輸入來源")).toBeInTheDocument();
expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument();
});
it("有 streamUrl 時 render <img> 指向該 URL", () => {
renderWithLocale(
<CameraFeed streamUrl="/api/camera/stream?_t=abc" sourceType="camera" />,
);
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(<CameraFeed streamUrl="/s" sourceType="camera" />);
expect(screen.getByAltText("即時攝影機畫面")).toBeInTheDocument();
expect(
screen.queryByTestId("camera-feed-source-label"),
).not.toBeInTheDocument();
});
it("image 來源使用「上傳的圖片」alt且顯示來源標籤", () => {
renderWithLocale(<CameraFeed streamUrl="/s" sourceType="image" />);
expect(screen.getByAltText("上傳的圖片")).toBeInTheDocument();
expect(screen.getByTestId("camera-feed-source-label")).toBeInTheDocument();
});
it("video 來源使用「影片播放」alt", () => {
renderWithLocale(<CameraFeed streamUrl="/s" sourceType="video" />);
expect(screen.getByAltText("影片播放")).toBeInTheDocument();
});
it("batchImageUrl 優先於 streamUrl", () => {
renderWithLocale(
<CameraFeed
streamUrl="/api/camera/stream"
batchImageUrl="/api/media/batch-images/2"
sourceType="batch_image"
/>,
);
const img = screen.getByTestId("camera-feed-img") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/api/media/batch-images/2");
});
it("render overlay slot 內容", () => {
renderWithLocale(
<CameraFeed
streamUrl="/s"
sourceType="camera"
overlay={<div data-testid="overlay-probe" />}
/>,
);
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(
<CameraFeed
streamUrl="/s"
sourceType="camera"
onDimensionsChange={onDimensionsChange}
/>,
);
expect(observe).toHaveBeenCalledTimes(1);
});
});

View File

@ -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`<img src=/api/camera/stream>`multipart/x-mixed-replace
* - placeholder
* - `overlay` slot 2 `<canvas>` absolute
* - `onDimensionsChange` `<img>` 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;
/** `<img>` 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<HTMLImageElement>(null);
// 觀察 `<img>` 實際 render 尺寸,回報給 overlayPOC 用 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 (
<div
className="bg-muted grid place-items-center rounded-lg border"
style={{ width, height: (width * 3) / 4 }}
data-testid="camera-feed-empty"
>
<div className="text-muted-foreground text-center">
<p className="text-sm">{t("workspace.camera.noSource")}</p>
<p className="mt-1 text-xs">{t("workspace.camera.noSourceHint")}</p>
</div>
</div>
);
}
const altText =
sourceType === "image" || sourceType === "batch_image"
? t("workspace.camera.altUploadedImage")
: sourceType === "video"
? t("workspace.camera.altVideoPlayback")
: t("workspace.camera.altCameraFeed");
return (
<div
className="relative overflow-hidden rounded-lg border"
data-testid="camera-feed"
>
{/* eslint-disable-next-line @next/next/no-img-element -- MJPEG multipart 串流必須用原生 <img>next/image 不支援 */}
<img
ref={imgRef}
src={displayUrl}
alt={altText}
style={{ width, height: "auto" }}
className="block"
data-testid="camera-feed-img"
/>
{overlay}
{sourceType && sourceType !== "camera" && (
<div
className={cn(
"bg-foreground/70 text-background absolute top-2 left-2 z-10",
"rounded px-2 py-1 text-xs",
)}
data-testid="camera-feed-source-label"
>
{sourceType === "image"
? t("workspace.tabs.image")
: sourceType === "batch_image"
? t("workspace.tabs.batch")
: t("workspace.tabs.video")}
</div>
)}
</div>
);
}

View File

@ -95,6 +95,14 @@ export function useWebSocket(
// OF2不再附加 token 到 querystringauth-store 已無 token 欄位)。 // OF2不再附加 token 到 querystringauth-store 已無 token 欄位)。
// BFF 模式下若 WS 與 API 同 origin瀏覽器會自動帶 visiona_session cookie // BFF 模式下若 WS 與 API 同 origin瀏覽器會自動帶 visiona_session cookie
// 若跨 origin待 OF7 / Phase 1 補上述 (a)/(b)/(c) 之一的安全認證機制。 // 若跨 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 URLhttp→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}`; return `${base}${normalizedPath}`;
} }

View File

@ -0,0 +1,52 @@
/**
* lib/camera 1
*
* buildStreamUrl
* - jsdomwindow + 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");
});
});

View File

@ -0,0 +1,42 @@
/**
* Camera / Media URL visionA Cloud
*
* POC
* POCedge-ai-platform使MJPEG `<img src>` relay token URL query
* `?token=...` relay-token-sync.tsxvisionA 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 `<img>` stream ** origin + cookie**
*
* origin
* stage / prod nginx `/api/*` host api.ts getApiBaseUrl
* `<img>` origin GET `visiona_session` cookie URL
*
* backend
* - api-server `/api/camera/stream` streaming proxy §3.1 stubs.go:48
* - origin devfrontend :3000 backend :3721 `<img>` cookie backend CORS
* credentials dev originbackend 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 `<img src>` 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)}`;
}

View File

@ -278,6 +278,18 @@ export const en: Dictionary = {
"workspace.header.title": "Workspace", "workspace.header.title": "Workspace",
"workspace.inference.start": "Start inference", "workspace.inference.start": "Start inference",
"workspace.inference.stop": "Stop 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": "workspace.placeholder.cameraComingSoon":
"Camera inference preview — F8 will wire up the MJPEG stream through the tunnel from local agent.", "Camera inference preview — F8 will wire up the MJPEG stream through the tunnel from local agent.",
"workspace.offline.title": "Device went offline", "workspace.offline.title": "Device went offline",

View File

@ -270,6 +270,16 @@ export const zhHant: Dictionary = {
"workspace.header.title": "工作區", "workspace.header.title": "工作區",
"workspace.inference.start": "開始推論", "workspace.inference.start": "開始推論",
"workspace.inference.stop": "停止推論", "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": "workspace.placeholder.cameraComingSoon":
"Camera 推論介面預覽 — F8 會接上 MJPEG stream透過 tunnel 從 local agent 中繼)", "Camera 推論介面預覽 — F8 會接上 MJPEG stream透過 tunnel 從 local agent 中繼)",
"workspace.offline.title": "裝置已離線", "workspace.offline.title": "裝置已離線",

View File

@ -0,0 +1,51 @@
/**
* Camera / Media visionA Cloud
*
*
* - local-tool/server/internal/api/handlers/camera_handler.go
* StartPipelinecamera/ UploadImage / UploadVideo / UploadBatchImages response
* - POC edge-ai-platform/frontend/src/types/camera.ts code
*
* MJPEG `<img>` + 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;
}

View File

@ -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:<deviceId>", result)
* local agent WS pipe api-server WS forward WS
*
* json key camelCase Go struct `json:"..."` tag
* overlay /
*
* bbox normalized0~1overlay 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:<deviceId>` 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;
}