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。
|
||||
*
|
||||
* 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<MediaUploadResponse>("/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";
|
||||
// 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 <img> 卡在已死的 MJPEG 連線)。
|
||||
// 用 render 期衍生而非 effect + setState,避免 cascading render。
|
||||
const effectiveStreamUrl = isOnline ? streamUrl : "";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
|
||||
<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">
|
||||
{t("workspace.placeholder.cameraComingSoon")}
|
||||
{t("workspace.camera.idleHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="min-h-[60vh]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Inference</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t("workspace.inference.panelTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{/* 雛形:InferencePanel stub。F8 補 ClassificationResult + PerformanceMetrics */}
|
||||
— Phase 1
|
||||
{/* 塊 2 補:接 WS `inference:<deviceId>` → ClassificationResult + PerformanceMetrics + overlay */}
|
||||
{t("workspace.inference.panelPending")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</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 欄位)。
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
|
||||
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.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",
|
||||
|
||||
@ -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": "裝置已離線",
|
||||
|
||||
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