jim800121chen af44a9f9ba feat(frontend): flash 完成工作區入口醒目化 + 影片上限 200→500MB
驗收後兩個 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>
2026-07-21 01:17:11 +08:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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` → 走 CameraFeedMJPEG `<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 APIPOST /api/media/seekbody 是 `{ 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 index0-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大檔經 tunnelcaller 端 timeout 設 0 = 不限,見 workspace-client VIDEO_UPLOAD_TIMEOUT_MS
export interface UploadMediaOptions {
/** 上傳進度 callback0~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 cookieBFF 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` 查 sessionsserial 反查),帶 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;
}