/**
* 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;
}