驗收後兩個 UX 微調: - flash 載入模型完成後「開啟工作區」入口不明顯、使用者要自己找 → Button 改 size=lg + ArrowRight 前往圖示 + ring-primary/40 highlight(設計系統 token、不裸色碼)。保留手動導航(點擊才去、無自動 router.push)、serial 空 disable + 離線 disable gate 維持 - 影片上傳上限 MAX_VIDEO_BYTES 200→500MB + i18n 兩語系 hint 文案。影片 upload timeout 本來就是 0(不限、後端決定)→ 500MB 不會撞 timeout Reviewer 通過(0C/0M/0Mi/1Sug、timeout 獨立驗證、i18n 無殘留 200MB)。 tsc/eslint 0、vitest 39 passed(499/500/501MB 邊界 mock size 無 OOM + 醒目化樣式/行為)、next build 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
||
* 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 `<img>`)+ 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<MediaUploadResponse> {
|
||
const url = `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
|
||
|
||
return new Promise<MediaUploadResponse>((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<MediaUploadResponse> {
|
||
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<MediaUploadResponse> {
|
||
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<MediaUploadResponse> {
|
||
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<SeekResponse> {
|
||
return api.post<SeekResponse>(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;
|
||
}
|