340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
"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}`
|
||
)
|
||
}
|
||
}
|
||
}
|