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:
parent
adab000987
commit
ba8fd454ea
@ -5,35 +5,38 @@
|
|||||||
*
|
*
|
||||||
* 對齊 pages.md §8.4、flow-offline-handling.md §6。
|
* 對齊 pages.md §8.4、flow-offline-handling.md §6。
|
||||||
*
|
*
|
||||||
* F6 範圍(雛形 / 骨架):
|
* 塊 1 範圍(Camera MJPEG 串流顯示 + 開始/停止狀態機):
|
||||||
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕
|
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕
|
||||||
* - Tabs:Camera / Image / Video / Batch(只有 Camera 做骨架,其他 stub 標示 Phase 1)
|
* - Tabs:Camera / Image / Video / Batch(Camera 已接真串流,其餘塊 3 補)
|
||||||
* - Camera tab 內:placeholder(F8 接 MJPEG stream + InferencePanel)
|
* - Camera tab 內:CameraFeed 顯示 MJPEG stream(start 成功後)
|
||||||
* - 裝置掉線(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);
|
||||||
|
// 塊 1:Camera MJPEG 串流 URL(start 成功後由後端回傳的 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-C1):camera 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">
|
||||||
|
{effectiveStreamUrl ? (
|
||||||
|
<CameraFeed
|
||||||
|
streamUrl={effectiveStreamUrl}
|
||||||
|
sourceType="camera"
|
||||||
|
width={640}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("workspace.placeholder.cameraComingSoon")}
|
{t("workspace.camera.idleHint")}
|
||||||
</p>
|
</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>
|
||||||
|
|||||||
117
visionA-frontend/src/components/workspace/camera-feed.test.tsx
Normal file
117
visionA-frontend/src/components/workspace/camera-feed.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
126
visionA-frontend/src/components/workspace/camera-feed.tsx
Normal file
126
visionA-frontend/src/components/workspace/camera-feed.tsx
Normal 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 尺寸,回報給 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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -95,6 +95,14 @@ export function useWebSocket(
|
|||||||
// OF2:不再附加 token 到 querystring(auth-store 已無 token 欄位)。
|
// OF2:不再附加 token 到 querystring(auth-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 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}`;
|
return `${base}${normalizedPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
visionA-frontend/src/lib/camera.test.ts
Normal file
52
visionA-frontend/src/lib/camera.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
42
visionA-frontend/src/lib/camera.ts
Normal file
42
visionA-frontend/src/lib/camera.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Camera / Media 串流 URL 工具 — visionA Cloud 前端
|
||||||
|
*
|
||||||
|
* 認證策略(與 POC 不同,重要):
|
||||||
|
* POC(edge-ai-platform)沒有使用者登入,MJPEG `<img src>` 靠 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 `<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 dev(frontend :3000 ↔ backend :3721)情境下 `<img>` 帶 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 組出瀏覽器可直接塞進 `<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)}`;
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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": "裝置已離線",
|
||||||
|
|||||||
51
visionA-frontend/src/types/camera.ts
Normal file
51
visionA-frontend/src/types/camera.ts
Normal file
@ -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 `<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;
|
||||||
|
}
|
||||||
64
visionA-frontend/src/types/inference.ts
Normal file
64
visionA-frontend/src/types/inference.ts
Normal 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 座標為 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:<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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user