Compare commits

..

2 Commits

Author SHA1 Message Date
725ac3cc54
Merge pull request #2 from jim800121/feat/adr-019-wp4-video-wiring
feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
2026-07-30 14:23:48 +08:00
b10fbb8091 feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
影片分頁上傳從舊 tunnel 路徑(/api/media/upload/video + 90MB)切到同機
localhost 直連(取 token → resolveLocalAgent → /api/local/media/upload/video)。
端到端啟用 ADR-019,取代 90MB 過渡限制。

- 新 lib/local-media.ts 編排層:getLocalUploadTicket + uploadVideoViaLocalAgent
- MAX_LOCAL_VIDEO_BYTES=500MB + validateLocalVideoFile(只綁 localhost 路徑;
  舊 MAX_VIDEO_BYTES=90MB + tunnel uploadVideo 完全不碰,向下相容)
- R-3 tunnel 離線三層防護:UI disable 不渲染 uploader + ticket 502 + 錯誤映射
- 5 種錯誤 i18n(NOT_FOUND/MISMATCH/離線/401/413)+ AbortError 靜默

reviewer 通過(0C/0M)。tsc/eslint/build 0 error、WP-4 相關 70 test pass。
⚠️ 實機驗證(真序號 hash 同形 fail-closed / PNA / 500MB 大檔實傳 / 混合路徑
結果面)待 stage 部署後驗證。Minor M-1/M-2 留 WP-6 一併處理。

Refs: ADR-019 WP-4。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:23:21 +08:00
9 changed files with 612 additions and 16 deletions

View File

@ -29,6 +29,18 @@ vi.mock("@/hooks/use-inference-stream", () => ({
useInferenceStream: vi.fn(),
}));
// ADR-019 WP-4mock 影片 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("不符");
});
});
});

View File

@ -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-M20 = 不限。 */
/**
* timeout0 =
*
* ADR-019 WP-4 localhost loopback0.12.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-3tunnel !isOnline + tunnel fallback
- error.code friendly i18nlocalVideoErrorMessage */}
<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) {
// 使用者取消AbortErrorapi.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));
}
}}
/>
)}

View File

@ -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-3tunnel 離線 → 停用上傳 + 明確提示(不顯示 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}

View File

@ -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",

View File

@ -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": "管理偏好與雲端端點",

View 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.postticket endpoint
* - mock `@/lib/local-agent` resolveLocalAgent / uploadToLocalAgentWP-3
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
// mock 雲端 api clientticket 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 });
});
});

View 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 cookieBFF+ tunnel forward
* - localhosttokenADR-019 §2.1
* - ** localhost tunnel fallback**ADR §4.2
* media.ts uploadVideo
*
* caller UI i18napi-spec §6.5
* - resolve `LocalMediaError`code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH
* - token / HTTP 沿 api.ts ApiErrorcode: LOCAL_TOKEN_INVALID /
* LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED /
* callerworkspace-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 endpointapi-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.3issue-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 serialNumberkn_numberADR-018 serial
* @throws ApiErrorTUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / envelope
*/
export function getLocalUploadTicket(
serial: string,
): Promise<LocalUploadTicket> {
// api.post 已解 envelope、回 data走 same-origin cookieBFF與其他雲端呼叫一致。
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 LocalMediaErrorcaller i18n
* 3. uploadToLocalAgent(port, /api/local/media/upload/video, form, { token })
*
* **caller ** tunnel R-3tunnel
* token tunnel issue-token 使
* caller UI disable workspace-client
*
* @param serial serialNumberkn_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,
});
}

View File

@ -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 MBADR-019 WP-4 放寬,常數校驗)", () => {
expect(MAX_LOCAL_VIDEO_BYTES).toBe(500 * 1024 * 1024);
});
it("validateLocalVideoFile91 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");

View File

@ -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
* 500MBADR-019 §4.3.1`http.MaxBytesReader` 500MB
* 500MBsecurity 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_BYTES500MB 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
*