/** * Media 上傳 / 推論工具 — visionA Cloud 前端(塊 3:圖片 / 影片 / 批次) * * 職責: * - `uploadMedia()`:以 multipart/form-data 上傳單檔(圖片 / 影片)到 * `/api/media/upload/image` | `/api/media/upload/video`,回傳解開 envelope 的 * `MediaUploadResponse`;支援上傳進度 + AbortSignal(大檔影片可取消)。 * - `uploadBatchImages()`:多檔(≤ MAX_BATCH_IMAGES)上傳到 * `/api/media/upload/batch-images`(欄位名 `files`,對齊 camera_handler.go:354)。 * - `seekVideo()`:`POST /api/media/seek`,body `{ timeSeconds }`(對齊 SeekVideo:541-547)。 * - `buildBatchImageUrl()`:組 `GET /api/media/batch-images/:index` 的完整 URL(回單張 jpeg)。 * * 為什麼不直接用 `api.upload`: * `api.upload` 的 body 是單一 Blob(用於 presigned PUT 直送 storage), * media 端需要 multipart form 帶額外欄位(`deviceId` + `file` / `files`), * 故這裡自寫 XHR + FormData。認證與 `api.upload` 一致:same-origin cookie * (`withCredentials = true`),不放 token 到 URL(對齊 lib/camera.ts / use-websocket §10)。 * * 顯示管線:上傳成功後拿 `streamUrl` → 走 CameraFeed(MJPEG ``)+ overlay + WS 面板, * 與 camera / 塊 1、2 完全共用(見 .autoflow/04-architecture/camera-e2e-effort-estimate.md §0.1)。 */ import { AbortError, ApiError, NetworkError, TimeoutError, api, getApiBaseUrl, } from "@/lib/api"; import type { ApiErrorShape } from "@/types/api"; import type { MediaUploadResponse, SeekResponse } from "@/types/camera"; /** 單檔上傳(圖片 / 影片)的後端路徑。 */ export const MEDIA_UPLOAD_IMAGE_PATH = "/api/media/upload/image"; export const MEDIA_UPLOAD_VIDEO_PATH = "/api/media/upload/video"; export const MEDIA_UPLOAD_BATCH_PATH = "/api/media/upload/batch-images"; export const MEDIA_SEEK_PATH = "/api/media/seek"; export const MEDIA_BATCH_FRAME_PATH = "/api/media/batch-images"; /** 批次最多張數(對齊 camera_handler.go:359 的 50 上限)。 */ export const MAX_BATCH_IMAGES = 50; /** * 影片播放取樣 fps 的 fallback 值。 * * ⚠️ 契約備註:seek API(POST /api/media/seek)body 是 `{ timeSeconds }`(秒數), * 但 WS 進度只給 frameIndex/totalFrames(無秒數)。把 frame → 秒需要 fps 或 duration。 * 後端 upload video response 有回 `durationSeconds`(camera_handler.go:333), * **優先用 duration 換算**(frame/totalFrames × durationSeconds),完全不依賴 fps。 * * 只有當後端沒回 durationSeconds(舊版 / 探測失敗)時,才退用此 fps 常數。 * 此值必須與後端 `camera_handler.go` 的 `h.videoFPS = 15`(line 303)同步; * 後端改 fps 時這裡也要跟著改(後端 response 目前不帶 fps,故無法自動同步)。 */ export const VIDEO_FALLBACK_FPS = 15; /** * 把 seek bar 的 frame 位置換算成後端 seek 需要的秒數。 * * @param frame 目標 frame index(0-based) * @param totalFrames 影片總 frame 數(WS 進度回報) * @param durationSeconds 影片總長秒數(upload response 回報;可能為 undefined) * @returns seek 目標秒數(≥ 0) * * 換算優先序: * 1. 有 durationSeconds + totalFrames → `frame / totalFrames × durationSeconds` * (只用後端回的欄位,無 fps 隱性耦合) * 2. 否則退用 `frame / VIDEO_FALLBACK_FPS`(具名常數,需與後端 fps 同步) */ export function frameToSeekSeconds( frame: number, totalFrames?: number, durationSeconds?: number, ): number { if ( typeof durationSeconds === "number" && durationSeconds > 0 && typeof totalFrames === "number" && totalFrames > 0 ) { return Math.max(0, (frame / totalFrames) * durationSeconds); } return Math.max(0, frame / VIDEO_FALLBACK_FPS); } /** 圖片副檔名白名單(對齊 UploadImage:155 / UploadBatchImages:369)。 */ export const IMAGE_ACCEPT = ".jpg,.jpeg,.png"; /** 影片副檔名白名單(對齊 UploadVideo:249)。 */ 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 = 500 * 1024 * 1024; // 500 MB(大檔經 tunnel,caller 端 timeout 設 0 = 不限,見 workspace-client VIDEO_UPLOAD_TIMEOUT_MS) export interface UploadMediaOptions { /** 上傳進度 callback(0~100) */ onProgress?: (percent: number) => void; /** 取消訊號(使用者按取消 / 切 tab / unmount) */ signal?: AbortSignal; /** 覆寫 timeout(毫秒);預設影片較長,見 caller。傳 0 關閉 timeout。 */ timeoutMs?: number; } /** * 以 XHR + FormData 上傳 multipart,解析後端統一 envelope。 * * @param path 後端相對路徑 * @param form 已組好的 FormData(含 deviceId + file/files) * @param options 進度 / 取消 / timeout * @returns 解開 envelope 的 `data`(MediaUploadResponse) * @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系) */ function postMultipart( path: string, form: FormData, options: UploadMediaOptions = {}, ): Promise { const url = `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", url, true); // same-origin cookie(BFF session);不設 Content-Type,讓瀏覽器帶 multipart boundary xhr.withCredentials = true; if (options.timeoutMs && options.timeoutMs > 0) { xhr.timeout = options.timeoutMs; } if (options.onProgress) { xhr.upload.onprogress = (ev) => { if (ev.lengthComputable) { options.onProgress!(Math.min(100, Math.round((ev.loaded / ev.total) * 100))); } }; } xhr.onload = () => { let parsed: unknown = null; try { parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch { // 非 JSON body } if (xhr.status >= 200 && xhr.status < 300) { if ( parsed && typeof parsed === "object" && "success" in parsed && (parsed as { success: boolean }).success === true && "data" in parsed ) { resolve((parsed as { data: MediaUploadResponse }).data); return; } reject( new ApiError(xhr.status, { code: "PARSE_ERROR", message: "Unexpected upload response shape", }), ); return; } // non-2xx:盡量取 envelope 的 error let errShape: ApiErrorShape = { code: xhr.status === 401 ? "UNAUTHORIZED" : "INTERNAL_ERROR", message: `Upload failed: HTTP ${xhr.status}`, }; if ( parsed && typeof parsed === "object" && "error" in parsed && (parsed as { error?: unknown }).error ) { errShape = (parsed as { error: ApiErrorShape }).error; } reject(new ApiError(xhr.status, errShape)); }; xhr.onerror = () => reject(new NetworkError(`Upload to ${url} failed`)); xhr.ontimeout = () => reject(new TimeoutError(`Upload to ${url} timed out`)); if (options.signal) { if (options.signal.aborted) { reject(new AbortError()); return; } options.signal.addEventListener( "abort", () => { xhr.abort(); reject(new AbortError()); }, { once: true }, ); } xhr.send(form); }); } /* * ⚠️ ADR-018 serial 路由:下方三個 upload 函式的裝置識別參數是 * `serialNumber`(kn_number),不是雲端 device UUID——local agent 端 * `camera_handler.go` 以 body 的 `deviceId` 查 sessions(serial 反查),帶 UUID 會 * "device not found"。FormData 欄位名維持 `deviceId`(傳輸結構不動、只換值來源)。 * serial 為空的裝置不可上傳(workspace 端 disable)。 */ /** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */ export function uploadImage( serialNumber: string, file: File, options?: UploadMediaOptions, ): Promise { const form = new FormData(); form.append("deviceId", serialNumber); form.append("file", file); return postMultipart(MEDIA_UPLOAD_IMAGE_PATH, form, options); } /** 上傳單支影片 → 開始逐 frame 推論;回傳含 streamUrl / totalFrames / durationSeconds。 */ export function uploadVideo( serialNumber: string, file: File, options?: UploadMediaOptions, ): Promise { const form = new FormData(); form.append("deviceId", serialNumber); form.append("file", file); return postMultipart(MEDIA_UPLOAD_VIDEO_PATH, form, options); } /** 上傳多張圖(batch)→ 逐張推論;回傳含 batchId / totalImages / images[]。 */ export function uploadBatchImages( serialNumber: string, files: File[], options?: UploadMediaOptions, ): Promise { const form = new FormData(); form.append("deviceId", serialNumber); for (const f of files) { form.append("files", f); } return postMultipart(MEDIA_UPLOAD_BATCH_PATH, form, options); } /** * 影片 seek — 跳到指定秒數並從該處重啟推論。 * body `{ timeSeconds }`(對齊 SeekVideo:541-547;後端以秒數 clamp,非 frameIndex)。 */ export function seekVideo(timeSeconds: number): Promise { return api.post(MEDIA_SEEK_PATH, { timeSeconds }); } /** * 組批次單張結果圖 URL(`GET /api/media/batch-images/:index` 回單張 jpeg)。 * * @param index 批次內索引(0-based) * @param cacheBust 可選 cache-busting(切換到同 index 但結果已更新時避免瀏覽器用舊圖) */ export function buildBatchImageUrl(index: number, cacheBust?: string): string { const base = getApiBaseUrl(); const full = `${base}${MEDIA_BATCH_FRAME_PATH}/${index}`; if (!cacheBust) return full; return `${full}?_t=${encodeURIComponent(cacheBust)}`; } /** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */ export interface FileValidationError { code: "TYPE" | "SIZE" | "COUNT" | "EMPTY"; filename?: string; } /** 依副檔名 + 大小驗證單張圖片。回傳 null 表通過。 */ export function validateImageFile(file: File): FileValidationError | null { if (!/\.(jpe?g|png)$/i.test(file.name)) { return { code: "TYPE", filename: file.name }; } if (file.size > MAX_IMAGE_BYTES) { return { code: "SIZE", filename: file.name }; } return null; } /** 依副檔名 + 大小驗證影片。回傳 null 表通過。 */ export function validateVideoFile(file: File): FileValidationError | null { if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) { return { code: "TYPE", filename: file.name }; } if (file.size > MAX_VIDEO_BYTES) { return { code: "SIZE", filename: file.name }; } return null; } /** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */ export function validateBatchFiles(files: File[]): FileValidationError | null { if (files.length === 0) return { code: "EMPTY" }; if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" }; for (const f of files) { const err = validateImageFile(f); if (err) return err; } return null; }