perf(video): perf

This commit is contained in:
2026-03-09 10:01:31 +08:00
parent 2402d685d7
commit 3b118e3304
12 changed files with 1284 additions and 328 deletions

View File

@@ -13,16 +13,41 @@ interface ProcessVideoParams {
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",
"fps=1,scale='min(720,iw)':-1",
// 基线策略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",
@@ -101,7 +126,7 @@ export const processVideo = async (
if (typeof window === "undefined") return []
const args = params.args && params.args.length ? params.args : defaultArgs
const outputExt = getOutputExtFromArgs(args)
const fileExt = getFileExt(params.fileName)
const prefix = sanitizePrefix(
params.namePrefix || stripFileExt(params.fileName)
)
@@ -109,90 +134,208 @@ export const processVideo = async (
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 worker = new Worker(
new URL("../ffmpeg/ffmpeg.worker.ts", import.meta.url)
)
let frameIndex = 0
const frames: VideoFrameFile[] = []
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/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 new Promise<VideoFrameFile[]>((resolve, reject) => {
let settled = false
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 finish = (next: () => void) => {
if (settled) return
settled = true
next()
}
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const message = event.data
switch (message.type) {
case "READY": {
const runMessage: WorkerMessage = {
type: "EXEC",
file: inputFile,
args,
outputPattern: "frame_",
}
worker.postMessage(runMessage)
return
}
case "SEGMENT_DATA": {
const files = message.files || []
files.forEach((file) => {
const ext = getFileExt(file.name) || outputExt || "jpg"
const frameName = `${prefix}_frame_${String(frameIndex).padStart(
6,
"0"
)}.${ext}`
frameIndex += 1
frames.push({
name: frameName,
dataUrl: `data:${mimeByExt[ext] || "image/jpeg"};base64,${uint8ArrayToBase64(file.data)}`,
})
})
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()
// 仅对可恢复错误做一次降级重试,避免无限重试拖垮页面。
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}`
)
}
}
}