Files
labelmain/components/label/ffmpeg/processVideo.ts
2026-03-10 14:15:53 +08:00

340 lines
10 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.

"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<string, string> = {
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<VideoFrameFile[]> => {
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<VideoFrameFile[]>((resolve, reject) => {
let settled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
let firstFrameTimeoutId: ReturnType<typeof setTimeout> | 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<WorkerResponse>) => {
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}`
)
}
}
}