"use client" import type { WorkerMessage, WorkerResponse } from "./ffmpeg.worker" export interface VideoFrameFile { name: string dataUrl: string } interface ProcessVideoParams { base64Data: string fileName: string namePrefix?: string baseURL?: string args?: string[] timeoutMs?: number firstFrameTimeoutMs?: number quickFirstFrame?: boolean continueAfterQuickFirstFrame?: boolean onFrames?: (frames: VideoFrameFile[]) => void onProgress?: (progress: number) => void } const defaultArgs = [ "-vf", // 基线策略:1fps + Lanczos 缩放 + 轻微锐化,兼顾清晰度与处理速度。 "fps=1,scale='min(720,iw)':-2:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.5:3:3:0", "-q:v", "2", "frame_%03d.jpg", ] const lowMemoryArgs = [ "-vf", // OOM 回退:降低分辨率并使用更轻滤镜,优先保稳定输出。 "fps=1,scale='min(480,iw)':-2:flags=bilinear,format=yuv420p", "-q:v", "4", "frame_%03d.jpg", ] const timeoutFallbackArgs = [ "-vf", // 超时回退:进一步降采样,减少单次解码计算量。 "fps=1,scale='min(360,iw)':-2:flags=bilinear,format=yuv420p", "-q:v", "5", "frame_%03d.jpg", ] const mimeByExt: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", mkv: "video/x-matroska", avi: "video/x-msvideo", h264: "video/h264", "264": "video/h264", } const getFileExt = (fileName: string) => { const segments = fileName.split(".") if (segments.length <= 1) return "" return segments[segments.length - 1].toLowerCase() } const stripFileExt = (fileName: string) => { const index = fileName.lastIndexOf(".") if (index <= 0) return fileName return fileName.slice(0, index) } const normalizeBase64 = (text: string) => { if (!text) return "" const raw = text.includes(",") ? text.split(",").slice(1).join(",") : text return raw.replace(/\s+/g, "") } const sanitizePrefix = (prefix: string) => { const safe = prefix .replace(/[^a-zA-Z0-9_-]+/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") return safe || "video" } const uint8ArrayToBase64 = (arr: Uint8Array) => { const chunkSize = 0x8000 let binary = "" for (let i = 0; i < arr.length; i += chunkSize) { binary += String.fromCharCode(...arr.subarray(i, i + chunkSize)) } return btoa(binary) } const base64ToFile = (base64Data: string, fileName: string) => { const payload = normalizeBase64(base64Data) if (!payload) throw new Error("empty video payload") const binary = atob(payload) const len = binary.length const bytes = new Uint8Array(len) for (let i = 0; i < len; i += 1) { bytes[i] = binary.charCodeAt(i) } const fileExt = getFileExt(fileName) return new File([bytes], fileName, { type: mimeByExt[fileExt] || "video/mp4", }) } const getOutputExtFromArgs = (args: string[]) => { for (let i = args.length - 1; i >= 0; i -= 1) { const ext = getFileExt(args[i]) if (ext) return ext } return "jpg" } export const processVideo = async ( params: ProcessVideoParams ): Promise => { if (typeof window === "undefined") return [] const args = params.args && params.args.length ? params.args : defaultArgs const fileExt = getFileExt(params.fileName) const prefix = sanitizePrefix( params.namePrefix || stripFileExt(params.fileName) ) const baseURL = params.baseURL || `${window.location.origin}${process.env.NEXT_PUBLIC_BASE_PATH}/wasm` const inputFile = base64ToFile(params.base64Data, params.fileName) const fileMB = inputFile.size / 1024 / 1024 const inferredTimeoutMs = (() => { // 大文件和裸流(.h264/.264)给予更长时间,避免误判超时。 let ms = 240_000 if (fileMB > 30) ms += Math.round((fileMB - 30) * 2_500) if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 600_000) return Math.min(ms, 1_200_000) })() const timeoutMs = Number.isFinite(params.timeoutMs) ? Math.max(10_000, Number(params.timeoutMs)) : inferredTimeoutMs const inferredFirstFrameTimeoutMs = (() => { let ms = 20_000 if (fileMB > 30) ms += Math.round((fileMB - 30) * 1_000) if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 45_000) return Math.min(ms, 120_000) })() const firstFrameTimeoutMs = Number.isFinite(params.firstFrameTimeoutMs) ? Math.max(8_000, Number(params.firstFrameTimeoutMs)) : inferredFirstFrameTimeoutMs const runOnce = async ( runArgs: string[], runTimeoutMs: number, runFirstFrameTimeoutMs: number ) => { const worker = new Worker(new URL("./ffmpeg.worker.ts", import.meta.url)) const outputExt = getOutputExtFromArgs(runArgs) let frameIndex = 0 const frames: VideoFrameFile[] = [] try { return await new Promise((resolve, reject) => { let settled = false let timeoutId: ReturnType | null = null let firstFrameTimeoutId: ReturnType | null = null const finish = (next: () => void) => { if (settled) return settled = true if (timeoutId) { clearTimeout(timeoutId) timeoutId = null } if (firstFrameTimeoutId) { clearTimeout(firstFrameTimeoutId) firstFrameTimeoutId = null } next() } timeoutId = setTimeout(() => { finish(() => reject( new Error( `video process timeout after ${Math.round(runTimeoutMs / 1000)}s` ) ) ) }, runTimeoutMs) firstFrameTimeoutId = setTimeout(() => { if (frames.length > 0) return finish(() => reject( new Error( `video process timeout before first frame after ${Math.round(runFirstFrameTimeoutMs / 1000)}s` ) ) ) }, runFirstFrameTimeoutMs) worker.onmessage = (event: MessageEvent) => { const message = event.data switch (message.type) { case "READY": { // Worker 加载完 wasm 后才允许真正执行抽帧命令。 const runMessage: WorkerMessage = { type: "EXEC", file: inputFile, args: runArgs, outputPattern: "frame_", quickFirstFrame: Boolean(params.quickFirstFrame), continueAfterQuickFirstFrame: Boolean( params.continueAfterQuickFirstFrame ), } worker.postMessage(runMessage) return } case "SEGMENT_DATA": { // 分片回传:边抽边传,降低一次性内存峰值并加快首屏可见时间。 const files = message.files || [] const batchFrames: VideoFrameFile[] = [] files.forEach((file) => { const ext = getFileExt(file.name) || outputExt || "jpg" const frameNo = String(frameIndex).padStart(6, "0") const frameName = `${prefix}_frame_${frameNo}.${ext}` frameIndex += 1 const frame = { name: frameName, dataUrl: `data:${mimeByExt[ext] || "image/jpeg"};base64,${uint8ArrayToBase64(file.data)}`, } frames.push(frame) batchFrames.push(frame) }) if (batchFrames.length) { if (firstFrameTimeoutId) { clearTimeout(firstFrameTimeoutId) firstFrameTimeoutId = null } try { params.onFrames?.(batchFrames) } catch (callbackError) { console.warn( "[processVideo] onFrames callback failed", callbackError ) } } return } case "PROGRESS": { try { params.onProgress?.(message.progress || 0) } catch (callbackError) { console.warn( "[processVideo] onProgress callback failed", callbackError ) } return } case "DONE": { finish(() => { if (!frames.length) { reject(new Error("video has no extracted frames")) } else { resolve(frames) } }) return } case "FATAL": { finish(() => reject(new Error(message.error || "video process fatal")) ) return } case "ERROR": { finish(() => reject(new Error(message.error || "video process error")) ) return } default: return } } worker.onerror = (event) => { finish(() => reject(new Error(event.message || "video worker error"))) } const loadMessage: WorkerMessage = { type: "LOAD", baseURL, } worker.postMessage(loadMessage) }) } finally { worker.terminate() } } try { return await runOnce(args, timeoutMs, firstFrameTimeoutMs) } catch (error) { const message = error instanceof Error ? error.message : String(error) const isOom = message.includes("memory access out of bounds") || message.includes("Cannot enlarge memory") || message.includes("Aborted") const isTimeout = message.includes("video process timeout") if (!isOom && !isTimeout) throw error // 仅对可恢复错误做一次降级重试,避免无限重试拖垮页面。 const retryArgs = isTimeout ? timeoutFallbackArgs : lowMemoryArgs const retryTimeoutMs = isTimeout ? Math.max(timeoutMs, 600_000) : Math.max(timeoutMs, 300_000) const retryFirstFrameTimeoutMs = Math.max(firstFrameTimeoutMs, 35_000) try { return await runOnce(retryArgs, retryTimeoutMs, retryFirstFrameTimeoutMs) } catch (retryError) { const retryMessage = retryError instanceof Error ? retryError.message : String(retryError) throw new Error( `video process failed after ${isTimeout ? "timeout" : "low-memory"} retry: ${retryMessage}` ) } } }