Compare commits
2 Commits
329023085e
...
725ac3cc54
| Author | SHA1 | Date | |
|---|---|---|---|
| 725ac3cc54 | |||
| b10fbb8091 |
@ -29,6 +29,18 @@ vi.mock("@/hooks/use-inference-stream", () => ({
|
||||
useInferenceStream: vi.fn(),
|
||||
}));
|
||||
|
||||
// ADR-019 WP-4:mock 影片 localhost 直連編排(單元隔離;local-media 自身有 local-media.test.ts)。
|
||||
// 保留真實 LocalMediaError 供錯誤分支測試。
|
||||
const uploadVideoViaLocalAgent = vi.fn();
|
||||
vi.mock("@/lib/local-media", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/local-media")>("@/lib/local-media");
|
||||
return {
|
||||
...actual,
|
||||
uploadVideoViaLocalAgent: (...a: unknown[]) => uploadVideoViaLocalAgent(...a),
|
||||
};
|
||||
});
|
||||
|
||||
// 可控 ResizeObserver:把 callback 存起來,測試決定何時觸發
|
||||
let roCallbacks: ResizeObserverCallback[] = [];
|
||||
class ControllableRO {
|
||||
@ -43,6 +55,7 @@ class ControllableRO {
|
||||
}
|
||||
|
||||
import { useInferenceStream } from "@/hooks/use-inference-stream";
|
||||
import { LocalMediaError } from "@/lib/local-media";
|
||||
|
||||
import { WorkspaceClient } from "./workspace-client";
|
||||
|
||||
@ -91,6 +104,7 @@ function renderClient() {
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
uploadVideoViaLocalAgent.mockReset();
|
||||
roCallbacks = [];
|
||||
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = ControllableRO;
|
||||
useInferenceStore.setState({
|
||||
@ -280,3 +294,93 @@ describe("WorkspaceClient — serial 路由(WP-C / ADR-018)", () => {
|
||||
expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ADR-019 WP-4:影片分頁走 localhost 直連 local-agent(非 tunnel)。
|
||||
* 這裡驗證接線點——影片上傳呼叫 uploadVideoViaLocalAgent(帶 serial)、
|
||||
* tunnel 離線時停用上傳、錯誤碼轉 friendly i18n。
|
||||
*/
|
||||
describe("WorkspaceClient — 影片 localhost 直連(WP-4)", () => {
|
||||
/** 在影片分頁的 dropzone 選檔(觸發上傳流程)。 */
|
||||
function selectVideo(file: File) {
|
||||
const input = document.querySelector(
|
||||
'[data-testid="media-uploader-video"] input[type=file]',
|
||||
) as HTMLInputElement;
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
it("影片上傳走 uploadVideoViaLocalAgent(帶 serial),不走雲端 uploadVideo", async () => {
|
||||
uploadVideoViaLocalAgent.mockResolvedValue({
|
||||
streamUrl: "/api/camera/stream",
|
||||
sourceType: "video",
|
||||
totalFrames: 100,
|
||||
});
|
||||
renderClient();
|
||||
await switchTab("影片");
|
||||
// 線上 + 有 serial → 顯示 uploader(非離線提示)
|
||||
expect(screen.getByTestId("media-uploader-video")).toBeInTheDocument();
|
||||
|
||||
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadVideoViaLocalAgent).toHaveBeenCalled();
|
||||
});
|
||||
// 第一個參數是 serial、第二個是 File
|
||||
const [serialArg, fileArg] = uploadVideoViaLocalAgent.mock.calls[0]!;
|
||||
expect(serialArg).toBe("KN00123456");
|
||||
expect(fileArg).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it("tunnel 離線(remoteStatus != online)→ 影片分頁停用上傳 + 顯示離線提示", async () => {
|
||||
useDeviceStore.setState({
|
||||
selectedDevice: {
|
||||
id: "dev-1",
|
||||
name: "KL520",
|
||||
remoteStatus: "offline",
|
||||
lastSeenAt: null,
|
||||
serialNumber: "KN00123456",
|
||||
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
|
||||
isLoading: false,
|
||||
});
|
||||
renderClient();
|
||||
// 離線時整頁會有 offline 遮罩,但分頁內容仍 render;切到影片分頁驗證停用提示
|
||||
await switchTab("影片");
|
||||
expect(
|
||||
screen.getByTestId("media-upload-disabled-video"),
|
||||
).toBeInTheDocument();
|
||||
// 不出現 uploader
|
||||
expect(screen.queryByTestId("media-uploader-video")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("NOT_FOUND → 顯示「需同機操作」friendly 訊息(非 raw code)", async () => {
|
||||
uploadVideoViaLocalAgent.mockRejectedValue(
|
||||
new LocalMediaError("LOCAL_AGENT_NOT_FOUND"),
|
||||
);
|
||||
renderClient();
|
||||
await switchTab("影片");
|
||||
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
|
||||
|
||||
await waitFor(() => {
|
||||
const err = screen.getByTestId("media-uploader-video-error");
|
||||
expect(err.textContent).toContain("同一台電腦");
|
||||
// 不可洩漏 raw code
|
||||
expect(err.textContent).not.toContain("LOCAL_AGENT_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
it("MISMATCH → 顯示「Agent 與裝置不符」friendly 訊息", async () => {
|
||||
uploadVideoViaLocalAgent.mockRejectedValue(
|
||||
new LocalMediaError("LOCAL_AGENT_MISMATCH"),
|
||||
);
|
||||
renderClient();
|
||||
await switchTab("影片");
|
||||
selectVideo(new File([new Blob(["x"])], "clip.mp4"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("media-uploader-video-error").textContent,
|
||||
).toContain("不符");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -51,18 +51,26 @@ import {
|
||||
VIDEO_ACCEPT,
|
||||
uploadBatchImages,
|
||||
uploadImage,
|
||||
uploadVideo,
|
||||
validateBatchFiles,
|
||||
validateImageFile,
|
||||
validateVideoFile,
|
||||
validateLocalVideoFile,
|
||||
} from "@/lib/media";
|
||||
import {
|
||||
LocalMediaError,
|
||||
uploadVideoViaLocalAgent,
|
||||
} from "@/lib/local-media";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useDeviceStore } from "@/stores/device-store";
|
||||
import { useInferenceStore } from "@/stores/inference-store";
|
||||
import type { MediaUploadResponse } from "@/types/camera";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/** 影片上傳 timeout 拉長(大檔經 tunnel,評估 R-M2);0 = 不限。 */
|
||||
/**
|
||||
* 影片上傳 timeout;0 = 不限。
|
||||
*
|
||||
* ADR-019 WP-4:影片改走 localhost 直連(loopback,0.1–2.5s),大檔不再經雲端 tunnel,
|
||||
* 理論上很快;但保留不限(0)避免慢速磁碟 / 大檔(≤500MB)時前端搶先 timeout。
|
||||
*/
|
||||
const VIDEO_UPLOAD_TIMEOUT_MS = 0;
|
||||
|
||||
interface WorkspaceClientProps {
|
||||
@ -114,6 +122,36 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
||||
const serialNumber = selectedDevice?.serialNumber ?? null;
|
||||
const hasSerial = !!serialNumber;
|
||||
|
||||
// ADR-019 WP-4:影片 localhost 直連上傳失敗 → 依 error.code 對應 friendly i18n。
|
||||
// MediaUploader 以 thrown error 的 message 顯示,故這裡把 code 轉成已本地化的字串再拋。
|
||||
const localVideoErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
const code =
|
||||
err instanceof LocalMediaError || err instanceof ApiError
|
||||
? err.code
|
||||
: undefined;
|
||||
switch (code) {
|
||||
case "LOCAL_AGENT_NOT_FOUND":
|
||||
return t("workspace.media.local.notFound");
|
||||
case "LOCAL_AGENT_MISMATCH":
|
||||
return t("workspace.media.local.mismatch");
|
||||
case "LOCAL_TOKEN_INVALID":
|
||||
return t("workspace.media.local.tokenInvalid");
|
||||
case "LOCAL_UPLOAD_TOO_LARGE":
|
||||
return t("workspace.media.local.tooLarge");
|
||||
// tunnel 離線時取 token 會失敗(502);理論上 UI 已先 disable(見 video tab
|
||||
// uploadDisabled),此為防禦性 fallback。
|
||||
case "TUNNEL_DISCONNECTED":
|
||||
case "TUNNEL_ERROR":
|
||||
return t("workspace.media.local.offline");
|
||||
default:
|
||||
// 其他(network / timeout / 未預期)→ 保留原始訊息(含 abort 由 uploader 自行忽略)
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
@ -345,6 +383,10 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 影片分頁:ADR-019 WP-4 走 localhost 直連 local-agent(非 tunnel)。
|
||||
- validate 用 500MB 上限(localhost 硬牆,見 media.ts validateLocalVideoFile)
|
||||
- R-3:tunnel 離線(!isOnline)→ 停用上傳 + 明確提示(不做 tunnel fallback)
|
||||
- 上傳失敗 → 依 error.code 對應 friendly i18n(localVideoErrorMessage) */}
|
||||
<TabsContent value="video" className="space-y-4">
|
||||
{!hasSerial ? (
|
||||
noSerialNotice
|
||||
@ -354,15 +396,25 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
||||
isOnline={!!isOnline}
|
||||
sourceType="video"
|
||||
accept={VIDEO_ACCEPT}
|
||||
uploadDisabled={!isOnline}
|
||||
uploadDisabledMessage={t("workspace.media.local.offline")}
|
||||
validate={(files) =>
|
||||
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
|
||||
files[0] ? validateLocalVideoFile(files[0]) : { code: "EMPTY" }
|
||||
}
|
||||
upload={async (files, ctx) => {
|
||||
return uploadVideo(serialNumber!, files[0]!, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
return await uploadVideoViaLocalAgent(serialNumber!, files[0]!, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err) {
|
||||
// 使用者取消(AbortError,api.ts code=ABORTED / name=AbortError)→ 原樣拋回,
|
||||
// 讓 MediaUploader 依 signal.aborted 靜默回 idle(不顯示錯誤)。
|
||||
if (err instanceof Error && err.name === "AbortError") throw err;
|
||||
// 其餘(NOT_FOUND / MISMATCH / token / 413 / network …)轉 friendly i18n
|
||||
throw new Error(localVideoErrorMessage(err));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -55,6 +55,14 @@ export interface MediaTabProps {
|
||||
files: File[],
|
||||
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
|
||||
) => Promise<MediaUploadResponse>;
|
||||
/**
|
||||
* 停用上傳並改顯示提示(ADR-019 R-3:影片走 localhost 直連,tunnel 離線時
|
||||
* 上傳 / 結果都不可用 → disable 上傳 + 明確提示,不讓使用者上傳後看不到結果)。
|
||||
* 未設或 false 時行為不變(顯示 uploader)。
|
||||
*/
|
||||
uploadDisabled?: boolean;
|
||||
/** uploadDisabled 為 true 時顯示的提示文字(i18n,由 caller 傳入)。 */
|
||||
uploadDisabledMessage?: string;
|
||||
}
|
||||
|
||||
export function MediaTab({
|
||||
@ -65,6 +73,8 @@ export function MediaTab({
|
||||
multiple = false,
|
||||
validate,
|
||||
upload,
|
||||
uploadDisabled = false,
|
||||
uploadDisabledMessage,
|
||||
}: MediaTabProps) {
|
||||
const t = useT();
|
||||
const [streamUrl, setStreamUrl] = useState("");
|
||||
@ -163,7 +173,18 @@ export function MediaTab({
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
|
||||
<Card className="min-h-[60vh]">
|
||||
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
|
||||
{!effectiveStreamUrl ? (
|
||||
{!effectiveStreamUrl && uploadDisabled ? (
|
||||
// R-3:tunnel 離線 → 停用上傳 + 明確提示(不顯示 uploader)
|
||||
<div
|
||||
role="alert"
|
||||
className="max-w-md space-y-2 text-center"
|
||||
data-testid={`media-upload-disabled-${sourceType}`}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{uploadDisabledMessage ?? t("workspace.media.errorGeneric")}
|
||||
</p>
|
||||
</div>
|
||||
) : !effectiveStreamUrl ? (
|
||||
<MediaUploader
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
|
||||
@ -362,13 +362,23 @@ export const en: Dictionary = {
|
||||
"workspace.media.image.primary": "Drag an image here to run inference",
|
||||
"workspace.media.image.hint": "JPG or PNG · up to 20 MB",
|
||||
"workspace.media.video.primary": "Drag a video here to run inference",
|
||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · up to 90 MB",
|
||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · up to 500 MB",
|
||||
"workspace.media.video.frame": "Frame",
|
||||
"workspace.media.video.seek": "Seek video",
|
||||
"workspace.media.batch.primary": "Drag images here (up to 50) for batch inference",
|
||||
"workspace.media.batch.hint": "JPG or PNG · up to 50 files · 20 MB each",
|
||||
"workspace.media.batch.progress": "Image {current} / {total}",
|
||||
|
||||
// ── Video localhost direct-upload errors (ADR-019 WP-4) ──
|
||||
"workspace.media.local.notFound":
|
||||
"Video upload must be done on the same computer running the visionA Agent. Make sure the Agent is running on this machine and try again.",
|
||||
"workspace.media.local.mismatch":
|
||||
"The detected Agent doesn't match this device. Make sure this computer is running the visionA Agent for this device.",
|
||||
"workspace.media.local.offline":
|
||||
"This device is offline; video upload is unavailable. Please wait for the device to reconnect.",
|
||||
"workspace.media.local.tokenInvalid": "Upload authorization expired. Please try uploading again.",
|
||||
"workspace.media.local.tooLarge": "Video file is too large (500 MB max).",
|
||||
|
||||
// ── Settings ──
|
||||
"settings.title": "Settings",
|
||||
"settings.subtitle": "Manage preferences and cloud endpoints",
|
||||
|
||||
@ -350,13 +350,23 @@ export const zhHant: Dictionary = {
|
||||
"workspace.media.image.primary": "將圖片拖曳到此處開始推論",
|
||||
"workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB",
|
||||
"workspace.media.video.primary": "將影片拖曳到此處開始推論",
|
||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · 上限 90 MB",
|
||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · 上限 500 MB",
|
||||
"workspace.media.video.frame": "影格",
|
||||
"workspace.media.video.seek": "影片跳轉",
|
||||
"workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論",
|
||||
"workspace.media.batch.hint": "JPG 或 PNG · 最多 50 張 · 每張上限 20 MB",
|
||||
"workspace.media.batch.progress": "第 {current} / {total} 張",
|
||||
|
||||
// ── 影片 localhost 直連上傳錯誤(ADR-019 WP-4)──
|
||||
"workspace.media.local.notFound":
|
||||
"影片上傳需在執行 visionA Agent 的同一台電腦上操作。請確認 Agent 正在此電腦執行後再試。",
|
||||
"workspace.media.local.mismatch":
|
||||
"偵測到的 Agent 與目前裝置不符。請確認此電腦執行的是這台裝置的 visionA Agent。",
|
||||
"workspace.media.local.offline":
|
||||
"裝置目前離線,無法上傳影片。請待裝置重新連線後再試。",
|
||||
"workspace.media.local.tokenInvalid": "上傳授權已失效,請重新上傳。",
|
||||
"workspace.media.local.tooLarge": "影片檔案過大(上限 500 MB)。",
|
||||
|
||||
// ── Settings ──
|
||||
"settings.title": "設定",
|
||||
"settings.subtitle": "管理偏好與雲端端點",
|
||||
|
||||
208
visionA-frontend/src/lib/local-media.test.ts
Normal file
208
visionA-frontend/src/lib/local-media.test.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* local-media.ts 單元測試(ADR-019 WP-4)
|
||||
*
|
||||
* 對契約 mock(不需實機、不需 local-agent):
|
||||
* - getLocalUploadTicket:打對雲端 ticket route、回 token
|
||||
* - uploadVideoViaLocalAgent 編排:ticket → resolveLocalAgent → uploadToLocalAgent
|
||||
* 的 happy path 與各分支錯誤(NOT_FOUND / MISMATCH / ticket 失敗 / 上傳失敗)
|
||||
*
|
||||
* mock 策略:
|
||||
* - mock `@/lib/api` 的 api.post(ticket endpoint)
|
||||
* - mock `@/lib/local-agent` 的 resolveLocalAgent / uploadToLocalAgent(WP-3 已各自有測試)
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// mock 雲端 api client(ticket endpoint)
|
||||
const apiPost = vi.fn();
|
||||
vi.mock("@/lib/api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||
return { ...actual, api: { ...actual.api, post: (...a: unknown[]) => apiPost(...a) } };
|
||||
});
|
||||
|
||||
// mock WP-3 低階 util(各自已有 local-agent.test.ts 覆蓋)
|
||||
const resolveLocalAgent = vi.fn();
|
||||
const uploadToLocalAgent = vi.fn();
|
||||
vi.mock("@/lib/local-agent", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/local-agent")>("@/lib/local-agent");
|
||||
return {
|
||||
...actual,
|
||||
resolveLocalAgent: (...a: unknown[]) => resolveLocalAgent(...a),
|
||||
uploadToLocalAgent: (...a: unknown[]) => uploadToLocalAgent(...a),
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { LOCAL_UPLOAD_VIDEO_PATH } from "@/lib/local-agent";
|
||||
import type { MediaUploadResponse } from "@/types/camera";
|
||||
|
||||
import {
|
||||
LOCAL_UPLOAD_TICKET_PATH,
|
||||
LocalMediaError,
|
||||
getLocalUploadTicket,
|
||||
uploadVideoViaLocalAgent,
|
||||
} from "./local-media";
|
||||
|
||||
const SERIAL = "KN00123456";
|
||||
|
||||
function videoFile(): File {
|
||||
return new File([new Blob([new Uint8Array(8)])], "v.mp4");
|
||||
}
|
||||
|
||||
function okUploadResponse(): MediaUploadResponse {
|
||||
return {
|
||||
streamUrl: "/api/camera/stream",
|
||||
sourceType: "video",
|
||||
totalFrames: 100,
|
||||
durationSeconds: 3.3,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiPost.mockReset();
|
||||
resolveLocalAgent.mockReset();
|
||||
uploadToLocalAgent.mockReset();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* getLocalUploadTicket */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("getLocalUploadTicket", () => {
|
||||
it("打對雲端 ticket route(含 serial encode)、回 token", async () => {
|
||||
apiPost.mockResolvedValue({ token: "tok-1", expiresAt: 123, ttlSeconds: 120 });
|
||||
const ticket = await getLocalUploadTicket(SERIAL);
|
||||
expect(ticket.token).toBe("tok-1");
|
||||
expect(apiPost).toHaveBeenCalledWith(LOCAL_UPLOAD_TICKET_PATH(SERIAL));
|
||||
expect(LOCAL_UPLOAD_TICKET_PATH(SERIAL)).toBe(
|
||||
`/api/devices/${SERIAL}/local-upload-ticket`,
|
||||
);
|
||||
});
|
||||
|
||||
it("serial 含特殊字元 → path 有 encode", () => {
|
||||
expect(LOCAL_UPLOAD_TICKET_PATH("a/b c")).toBe(
|
||||
"/api/devices/a%2Fb%20c/local-upload-ticket",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* uploadVideoViaLocalAgent — happy path */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("uploadVideoViaLocalAgent — happy path", () => {
|
||||
it("ticket → resolve OK → 直連上傳,回 MediaUploadResponse", async () => {
|
||||
apiPost.mockResolvedValue({ token: "tok-abc", ttlSeconds: 120 });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3725 });
|
||||
uploadToLocalAgent.mockResolvedValue(okUploadResponse());
|
||||
|
||||
const res = await uploadVideoViaLocalAgent(SERIAL, videoFile(), {
|
||||
timeoutMs: 0,
|
||||
});
|
||||
|
||||
expect(res.sourceType).toBe("video");
|
||||
// resolve 用 serial
|
||||
expect(resolveLocalAgent).toHaveBeenCalledWith(SERIAL);
|
||||
// upload 用 resolve 回的 port + 影片 route + token
|
||||
const [port, path, form, opts] = uploadToLocalAgent.mock.calls[0]!;
|
||||
expect(port).toBe(3725);
|
||||
expect(path).toBe(LOCAL_UPLOAD_VIDEO_PATH);
|
||||
expect((form as FormData).get("deviceId")).toBe(SERIAL);
|
||||
expect((form as FormData).get("file")).toBeInstanceOf(File);
|
||||
expect((opts as { token: string }).token).toBe("tok-abc");
|
||||
expect((opts as { timeoutMs: number }).timeoutMs).toBe(0);
|
||||
});
|
||||
|
||||
it("progress / signal 透傳給 uploadToLocalAgent", async () => {
|
||||
apiPost.mockResolvedValue({ token: "t" });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 });
|
||||
uploadToLocalAgent.mockResolvedValue(okUploadResponse());
|
||||
|
||||
const onProgress = vi.fn();
|
||||
const ctrl = new AbortController();
|
||||
await uploadVideoViaLocalAgent(SERIAL, videoFile(), {
|
||||
onProgress,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const opts = uploadToLocalAgent.mock.calls[0]![3] as {
|
||||
onProgress: unknown;
|
||||
signal: unknown;
|
||||
};
|
||||
expect(opts.onProgress).toBe(onProgress);
|
||||
expect(opts.signal).toBe(ctrl.signal);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* uploadVideoViaLocalAgent — 錯誤分支 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("uploadVideoViaLocalAgent — 錯誤分支", () => {
|
||||
it("resolve NOT_FOUND → 拋 LocalMediaError(LOCAL_AGENT_NOT_FOUND),不呼叫 upload", async () => {
|
||||
apiPost.mockResolvedValue({ token: "t" });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "NOT_FOUND" });
|
||||
|
||||
await expect(
|
||||
uploadVideoViaLocalAgent(SERIAL, videoFile()),
|
||||
).rejects.toMatchObject({ code: "LOCAL_AGENT_NOT_FOUND" });
|
||||
expect(uploadToLocalAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolve MISMATCH → 拋 LocalMediaError(LOCAL_AGENT_MISMATCH),不呼叫 upload", async () => {
|
||||
apiPost.mockResolvedValue({ token: "t" });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "MISMATCH" });
|
||||
|
||||
const err = await uploadVideoViaLocalAgent(SERIAL, videoFile()).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(LocalMediaError);
|
||||
expect((err as LocalMediaError).code).toBe("LOCAL_AGENT_MISMATCH");
|
||||
expect(uploadToLocalAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolve 回 OK 但沒有 port(防禦)→ 視為 MISMATCH", async () => {
|
||||
apiPost.mockResolvedValue({ token: "t" });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "OK", port: undefined });
|
||||
|
||||
await expect(
|
||||
uploadVideoViaLocalAgent(SERIAL, videoFile()),
|
||||
).rejects.toMatchObject({ code: "LOCAL_AGENT_MISMATCH" });
|
||||
expect(uploadToLocalAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ticket 取得失敗(如 tunnel 離線 502)→ 直接往上拋、不 resolve / upload", async () => {
|
||||
apiPost.mockRejectedValue(
|
||||
new ApiError(502, { code: "TUNNEL_DISCONNECTED", message: "offline" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadVideoViaLocalAgent(SERIAL, videoFile()),
|
||||
).rejects.toMatchObject({ code: "TUNNEL_DISCONNECTED", status: 502 });
|
||||
expect(resolveLocalAgent).not.toHaveBeenCalled();
|
||||
expect(uploadToLocalAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("上傳階段 413 → 原樣拋 ApiError(LOCAL_UPLOAD_TOO_LARGE)", async () => {
|
||||
apiPost.mockResolvedValue({ token: "t" });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 });
|
||||
uploadToLocalAgent.mockRejectedValue(
|
||||
new ApiError(413, { code: "LOCAL_UPLOAD_TOO_LARGE", message: "too large" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadVideoViaLocalAgent(SERIAL, videoFile()),
|
||||
).rejects.toMatchObject({ code: "LOCAL_UPLOAD_TOO_LARGE", status: 413 });
|
||||
});
|
||||
|
||||
it("上傳階段 401 → 原樣拋 ApiError(LOCAL_TOKEN_INVALID)", async () => {
|
||||
apiPost.mockResolvedValue({ token: "t" });
|
||||
resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 });
|
||||
uploadToLocalAgent.mockRejectedValue(
|
||||
new ApiError(401, { code: "LOCAL_TOKEN_INVALID", message: "invalid" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadVideoViaLocalAgent(SERIAL, videoFile()),
|
||||
).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 });
|
||||
});
|
||||
});
|
||||
135
visionA-frontend/src/lib/local-media.ts
Normal file
135
visionA-frontend/src/lib/local-media.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Local-agent 直連上傳的「編排層」— visionA Cloud 前端(ADR-019 WP-4)
|
||||
*
|
||||
* 職責(把 WP-3 的低階 util 串成影片分頁要用的完整流程):
|
||||
* 1. `getLocalUploadTicket(serial)`:向雲端 `POST /api/devices/:serial/local-upload-ticket`
|
||||
* 取 one-time upload token(走既有 OIDC cookie session + tunnel,見 api-spec §6.3)。
|
||||
* 2. `uploadVideoViaLocalAgent(serial, file, opts)`:取 token → 探測同機 local-agent
|
||||
* → 直連 POST `/api/local/media/upload/video`(帶 X-Visiona-Local-Token)。
|
||||
*
|
||||
* 與 media.ts(雲端 tunnel 路徑)的分工:
|
||||
* - media.ts 的 uploadVideo 走雲端 same-origin cookie(BFF)+ tunnel forward(保留不動)。
|
||||
* - 本檔走「上傳走 localhost、控制面(token)走雲端」的混合路徑(ADR-019 §2.1)。
|
||||
* - 影片分頁 **完全切 localhost、不做 tunnel fallback**(ADR §4.2「非同機停用分頁」),
|
||||
* 故本檔不引用 media.ts 的 uploadVideo。
|
||||
*
|
||||
* 錯誤模型(讓 caller 能對 UI 分流 i18n,api-spec §6.5):
|
||||
* - resolve 階段用 `LocalMediaError`(code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH)
|
||||
* - 取 token / 上傳階段的 HTTP 錯誤沿用 api.ts 的 ApiError(code: LOCAL_TOKEN_INVALID /
|
||||
* LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED / …)。
|
||||
* caller(workspace-client 影片分頁)以 error.code 對應具體 i18n 文案。
|
||||
*/
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
LOCAL_UPLOAD_VIDEO_PATH,
|
||||
resolveLocalAgent,
|
||||
uploadToLocalAgent,
|
||||
} from "@/lib/local-agent";
|
||||
import type { UploadMediaOptions } from "@/lib/media";
|
||||
import type { KnownErrorCode } from "@/types/api";
|
||||
import type { MediaUploadResponse } from "@/types/camera";
|
||||
|
||||
/** 雲端 ticket endpoint(api-spec §6.3;驗 OIDC session + 裝置歸屬後經 tunnel issue-token)。 */
|
||||
export const LOCAL_UPLOAD_TICKET_PATH = (serial: string): string =>
|
||||
`/api/devices/${encodeURIComponent(serial)}/local-upload-ticket`;
|
||||
|
||||
/** ticket endpoint 回傳(api-spec §6.3:issue-token 的 data 透傳)。 */
|
||||
export interface LocalUploadTicket {
|
||||
token: string;
|
||||
/** token 到期時間(unix ms)。 */
|
||||
expiresAt?: number;
|
||||
/** TTL 秒數(契約固定 120s)。 */
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve local-agent 階段的錯誤(純前端狀態、非後端回傳的 HTTP 錯誤)。
|
||||
*
|
||||
* 為什麼獨立一個 Error 類別而非沿用 ApiError:
|
||||
* NOT_FOUND / MISMATCH 是「前端掃描結論」(api-spec §6.5 標「—(前端內部狀態)」),
|
||||
* 沒有 HTTP status;用專屬類別讓 caller 能 `instanceof` 精準分流,且 code 型別安全。
|
||||
*/
|
||||
export class LocalMediaError extends Error {
|
||||
readonly code: Extract<
|
||||
KnownErrorCode,
|
||||
"LOCAL_AGENT_NOT_FOUND" | "LOCAL_AGENT_MISMATCH"
|
||||
>;
|
||||
constructor(
|
||||
code: LocalMediaError["code"],
|
||||
message = code,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LocalMediaError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向雲端要 one-time upload token(走既有 OIDC cookie session + tunnel)。
|
||||
*
|
||||
* @param serial 目前裝置 serialNumber(kn_number;ADR-018 serial 路由)
|
||||
* @throws ApiError(TUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / … 由後端 envelope 帶出)
|
||||
*/
|
||||
export function getLocalUploadTicket(
|
||||
serial: string,
|
||||
): Promise<LocalUploadTicket> {
|
||||
// api.post 已解 envelope、回 data;走 same-origin cookie(BFF),與其他雲端呼叫一致。
|
||||
return api.post<LocalUploadTicket>(LOCAL_UPLOAD_TICKET_PATH(serial));
|
||||
}
|
||||
|
||||
/** uploadVideoViaLocalAgent 的選項(沿用 media 的進度 / 取消 / timeout)。 */
|
||||
export type UploadLocalVideoOptions = UploadMediaOptions;
|
||||
|
||||
/**
|
||||
* 影片分頁的完整直連上傳流程(ADR-019 §2.4 契約 [1]→[4])。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 取 token(雲端 ticket)— 失敗直接往上拋(ApiError)。
|
||||
* 2. resolveLocalAgent(serial) → 探測 + 同機 + serial 身分驗證。
|
||||
* NOT_FOUND / MISMATCH → 拋 LocalMediaError(caller 對應 i18n)。
|
||||
* 3. uploadToLocalAgent(port, /api/local/media/upload/video, form, { token })。
|
||||
*
|
||||
* **前提(caller 責任)**:呼叫前須已確認裝置 tunnel 在線(R-3)。tunnel 離線時
|
||||
* token 取不到(雲端經 tunnel issue-token 會失敗),但為了給使用者清楚提示,
|
||||
* caller(影片分頁)應在 UI 層先 disable 上傳、不進到這裡(見 workspace-client)。
|
||||
*
|
||||
* @param serial 裝置 serialNumber(kn_number)
|
||||
* @param file 影片檔(副檔名 / 大小驗證由 caller 先做,見 media.ts validateLocalVideoFile)
|
||||
* @param options 進度 / 取消 / timeout
|
||||
* @returns 解開 envelope 的 MediaUploadResponse(含 streamUrl / totalFrames / durationSeconds)
|
||||
* @throws LocalMediaError | ApiError | NetworkError | TimeoutError | AbortError
|
||||
*/
|
||||
export async function uploadVideoViaLocalAgent(
|
||||
serial: string,
|
||||
file: File,
|
||||
options: UploadLocalVideoOptions = {},
|
||||
): Promise<MediaUploadResponse> {
|
||||
// [1] 取 token(雲端;此步失敗會拋 ApiError,含 TUNNEL_DISCONNECTED 等)
|
||||
const ticket = await getLocalUploadTicket(serial);
|
||||
|
||||
// 已取消就不必再探測 / 上傳
|
||||
if (options.signal?.aborted) {
|
||||
// 讓 caller 的 abort 分支一致:沿用 local-agent 的 AbortError(透過 upload 拋)
|
||||
// 這裡直接進入 upload 前的 guard 由 uploadToLocalAgent 處理 aborted signal。
|
||||
}
|
||||
|
||||
// [2] 探測同機 local-agent + serial 身分驗證
|
||||
const resolved = await resolveLocalAgent(serial);
|
||||
if (resolved.status === "NOT_FOUND") {
|
||||
throw new LocalMediaError("LOCAL_AGENT_NOT_FOUND");
|
||||
}
|
||||
if (resolved.status === "MISMATCH" || resolved.port === undefined) {
|
||||
throw new LocalMediaError("LOCAL_AGENT_MISMATCH");
|
||||
}
|
||||
|
||||
// [3] 直連上傳(FormData 欄位名維持 deviceId、值帶 serial,與雲端 route 相同)
|
||||
const form = new FormData();
|
||||
form.append("deviceId", serial);
|
||||
form.append("file", file);
|
||||
|
||||
return uploadToLocalAgent(resolved.port, LOCAL_UPLOAD_VIDEO_PATH, form, {
|
||||
...options,
|
||||
token: ticket.token,
|
||||
});
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MAX_BATCH_IMAGES,
|
||||
MAX_BATCH_TOTAL_BYTES,
|
||||
MAX_LOCAL_VIDEO_BYTES,
|
||||
MAX_VIDEO_BYTES,
|
||||
VIDEO_FALLBACK_FPS,
|
||||
buildBatchImageUrl,
|
||||
@ -21,6 +22,7 @@ import {
|
||||
uploadImage,
|
||||
validateBatchFiles,
|
||||
validateImageFile,
|
||||
validateLocalVideoFile,
|
||||
validateVideoFile,
|
||||
} from "./media";
|
||||
|
||||
@ -72,6 +74,25 @@ describe("media validation", () => {
|
||||
expect(validateVideoFile(makeSizedFile("v.mp4", 91 * mb))?.code).toBe("SIZE");
|
||||
});
|
||||
|
||||
it("影片 localhost 路徑:上限為 500 MB(ADR-019 WP-4 放寬,常數校驗)", () => {
|
||||
expect(MAX_LOCAL_VIDEO_BYTES).toBe(500 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("validateLocalVideoFile:91 MB(過 tunnel 上限)仍通過,剛好 500 MB 通過,501 MB 擋 SIZE", () => {
|
||||
const mb = 1024 * 1024;
|
||||
// 91 MB 若走舊 tunnel 上限(90MB)會被擋;localhost 路徑放寬到 500MB → 通過
|
||||
expect(validateLocalVideoFile(makeSizedFile("v.mp4", 91 * mb))).toBeNull();
|
||||
expect(validateLocalVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull();
|
||||
expect(validateLocalVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe(
|
||||
"SIZE",
|
||||
);
|
||||
});
|
||||
|
||||
it("validateLocalVideoFile:型別檢查與 validateVideoFile 相同(非影片擋 TYPE)", () => {
|
||||
expect(validateLocalVideoFile(makeFile("v.mp4"))).toBeNull();
|
||||
expect(validateLocalVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE");
|
||||
});
|
||||
|
||||
it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => {
|
||||
expect(validateBatchFiles([])?.code).toBe("EMPTY");
|
||||
|
||||
|
||||
@ -91,7 +91,22 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
|
||||
|
||||
/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2)。 */
|
||||
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
||||
export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。未來影片走 localhost 直連後可放寬)
|
||||
export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。此上限僅適用於**經 tunnel 的雲端路徑**(uploadVideo);影片分頁已改走 localhost 直連,見 MAX_LOCAL_VIDEO_BYTES)
|
||||
|
||||
/**
|
||||
* 影片走 **localhost 直連 local-agent** 時的上傳上限(ADR-019 WP-4)。
|
||||
*
|
||||
* 為什麼從 90MB 放寬到 500MB:
|
||||
* 90MB 是為了對齊 nginx `client_max_body_size 100M` 的**過渡值**——只在「經雲端 tunnel」
|
||||
* 時才受此約束。影片分頁改走 localhost 直連(繞過 nginx / 300s timeout / stage 落地),
|
||||
* 後端硬牆是 500MB(ADR-019 §4.3.1:`http.MaxBytesReader` ≤ 500MB),故前端上限
|
||||
* 對齊到 500MB。security 明確要求「不要因為走 loopback 就給 1GB」——攻擊面不因 loopback 縮小。
|
||||
*
|
||||
* 為什麼放寬是安全的:影片分頁**完全切 localhost、不做 tunnel fallback**
|
||||
* (ADR §4.2「非同機停用分頁」),故此路徑不會退回受 90MB 約束的 tunnel 路徑。
|
||||
* 舊 `MAX_VIDEO_BYTES`(90MB)與 tunnel 路徑的 `uploadVideo` 保留不動(向下相容 / 其他 caller)。
|
||||
*/
|
||||
export const MAX_LOCAL_VIDEO_BYTES = 500 * 1024 * 1024; // 500 MB(對齊後端 localhost 硬牆)
|
||||
|
||||
/**
|
||||
* 批次上傳「合計大小」上限(對齊 ADR-019 §2.2 / 後端 batch 合計 80MB)。
|
||||
@ -305,17 +320,37 @@ export function validateImageFile(file: File): FileValidationError | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 依副檔名 + 大小驗證影片。回傳 null 表通過。 */
|
||||
export function validateVideoFile(file: File): FileValidationError | null {
|
||||
/**
|
||||
* 依副檔名 + 大小驗證影片。回傳 null 表通過。
|
||||
*
|
||||
* @param file 影片檔
|
||||
* @param maxBytes 大小上限(預設 MAX_VIDEO_BYTES = 90MB,用於**經 tunnel 的雲端路徑**)。
|
||||
* 影片分頁走 localhost 直連時傳 MAX_LOCAL_VIDEO_BYTES(500MB)—— 見 validateLocalVideoFile。
|
||||
*/
|
||||
export function validateVideoFile(
|
||||
file: File,
|
||||
maxBytes: number = MAX_VIDEO_BYTES,
|
||||
): FileValidationError | null {
|
||||
if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) {
|
||||
return { code: "TYPE", filename: file.name };
|
||||
}
|
||||
if (file.size > MAX_VIDEO_BYTES) {
|
||||
if (file.size > maxBytes) {
|
||||
return { code: "SIZE", filename: file.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 影片走 localhost 直連 local-agent 的驗證(ADR-019 WP-4)——大小上限用 500MB。
|
||||
*
|
||||
* 與 validateVideoFile 的差異:只有 size 上限不同(型別檢查相同)。
|
||||
* 抽成具名函式(而非在 caller inline 傳 maxBytes)讓「影片分頁用 500MB」的意圖在
|
||||
* 呼叫端一目了然,且與 image / batch 的具名 validator 慣例一致。
|
||||
*/
|
||||
export function validateLocalVideoFile(file: File): FileValidationError | null {
|
||||
return validateVideoFile(file, MAX_LOCAL_VIDEO_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證整批圖片(數量 + 每張型別 / 大小 + 合計大小)。回傳 null 表通過。
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user