perf(component): perf

This commit is contained in:
2026-03-10 14:15:53 +08:00
parent 03ab0af15e
commit 3a1d1a43ee
19 changed files with 3 additions and 703 deletions

View File

@@ -10,7 +10,6 @@ import {
} from "react"
import ScaleComponent from "./components/ScaleComponent"
import { processVideo } from "@/app/component/label/video2image/processVideo"
import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core"
import { notifications, showNotification } from "@mantine/notifications"
import { getLabelResult, getServerImage } from "./api/label"
@@ -35,6 +34,7 @@ import RightQATools from "./components/RightQATools"
import RightTaskTools from "./components/RightTaskTools"
import ScaleToolContainer from "./components/ScaleToolContainer"
import TopTools from "./components/TopTools"
import { processVideo } from "./ffmpeg/processVideo"
import {
useImagesStore,
useKeyEventStore,

View File

@@ -0,0 +1,925 @@
// Define types for worker messages
export type WorkerMessage =
| { type: "LOAD"; baseURL: string }
| {
type: "EXEC"
file: File
args: string[]
outputPattern: string
quickFirstFrame?: boolean
continueAfterQuickFirstFrame?: boolean
}
export type WorkerResponse =
| { type: "READY" }
| { type: "LOG"; message: string }
| { type: "PROGRESS"; progress: number }
| { type: "DONE" }
| { type: "SEGMENT_DATA"; files: { name: string; data: Uint8Array }[] }
| { type: "FATAL"; error: string }
| { type: "ERROR"; error: string }
import { FFmpeg } from "@ffmpeg/ffmpeg"
const ctx: Worker = self as any
const ffmpeg = new FFmpeg()
let loaded = false
let lastBaseURL: string | null = null
// Custom Logger
ffmpeg.on("log", ({ message }) => {
ctx.postMessage({ type: "LOG", message })
})
ffmpeg.on("progress", ({ progress }) => {
ctx.postMessage({ type: "PROGRESS", progress })
})
ctx.onmessage = async (event: MessageEvent<WorkerMessage>) => {
const { type } = event.data
try {
switch (type) {
case "LOAD":
// Worker 生命周期内只加载一次 wasm后续任务复用。
if (loaded) {
ctx.postMessage({ type: "READY" })
return
}
const { baseURL } = event.data
lastBaseURL = baseURL
await ffmpeg.load({
coreURL: `${baseURL}/ffmpeg-core.js`,
wasmURL: `${baseURL}/ffmpeg-core.wasm`,
})
loaded = true
ctx.postMessage({ type: "READY" })
break
case "EXEC":
if (!loaded) throw new Error("FFmpeg not loaded")
const {
file,
args,
outputPattern,
quickFirstFrame,
continueAfterQuickFirstFrame,
} = event.data
ctx.postMessage({
type: "LOG",
message: `[start] outputPattern=${outputPattern}`,
})
const mountDir = "/input_mount"
let inputPath = `${mountDir}/${file.name}`
const mountInput = async () => {
// 用 WORKERFS 直接挂载 File避免先复制到 wasm FS 的额外内存占用。
await ffmpeg.createDir(mountDir)
await ffmpeg.mount("WORKERFS" as any, { files: [file] }, mountDir)
}
const unmountInput = async () => {
await ffmpeg.unmount(mountDir).catch((err) => {
console.log("unmounterr", err)
})
await ffmpeg.deleteDir(mountDir).catch((err) => {
console.log("deleteDir", err)
})
}
await mountInput()
const isRawH264 =
file.name.toLowerCase().endsWith(".264") ||
file.name.toLowerCase().endsWith(".h264")
if (isRawH264) {
ctx.postMessage({
type: "LOG",
message: "[fix] 检测到 H.264 裸流,正在封装为 MP4 以修复索引...",
})
const remuxedPath = "fixed_container.mp4"
try {
// -f h264: 强制指定输入格式为 h264 裸流
// -i ... : 输入
// -c copy: 直接复制流,不转码(速度极快)
// -f mp4 : 输出为 MP4 容器
const ret = await ffmpeg.exec([
"-f",
"h264",
"-i",
inputPath,
"-c",
"copy",
remuxedPath,
])
if (ret === 0) {
ctx.postMessage({
type: "LOG",
message: "[fix] 封装成功,切换输入源",
})
// 关键:将后续操作的输入路径指向新生成的 MP4
inputPath = remuxedPath
} else {
ctx.postMessage({
type: "LOG",
message: "[fix] 封装失败,尝试继续使用原始文件...",
})
}
} catch (e) {
ctx.postMessage({
type: "LOG",
message: `[fix] 预处理出错: ${String(e)}`,
})
}
}
try {
const normalizeError = (e: unknown) => {
if (typeof e === "string") return e
if (e && typeof e === "object" && "message" in e)
return String((e as any).message)
return String(e)
}
const deleteFileQuietly = async (filePath: string) => {
try {
await ffmpeg.deleteFile(filePath)
} catch (e) {
const msg = normalizeError(e)
const lower = msg.toLowerCase()
// Cleanup path may not exist (e.g. ffprobe output not created).
if (
lower.includes("no such file") ||
lower.includes("enoent") ||
(lower.includes("errnoerror") && lower.includes("fs error"))
) {
return
}
ctx.postMessage({
type: "LOG",
message: `[cleanup] deleteFile failed path=${filePath}: ${msg}`,
})
}
}
const isOom = (msg: string) =>
msg.includes("memory access out of bounds") ||
msg.includes("Cannot enlarge memory") ||
msg.includes("Aborted")
const nowMs = () =>
typeof performance !== "undefined" ? performance.now() : Date.now()
const makeThrottledLogger = (intervalMs: number) => {
let last = 0
return (message: string) => {
const t = nowMs()
if (t - last < intervalMs) return
last = t
ctx.postMessage({ type: "LOG", message })
}
}
const logThrottled = makeThrottledLogger(1000)
const metrics = {
startedAtMs: nowMs(),
mode: "unknown",
segmentAttempts: 0,
segmentDurationSec: 0,
fallbackToSingle: false,
reloadCount: 0,
frameCount: 0,
execMs: 0,
readMs: 0,
}
const fileMB = file.size / 1024 / 1024
const filterGuardLevel = (() => {
if (isRawH264) return fileMB >= 40 ? 2 : 1
if (fileMB >= 60) return 2
if (fileMB >= 30) return 1
return 0
})()
const runtimeArgs = (() => {
const nextArgs = [...args]
if (filterGuardLevel <= 0) return nextArgs
const vfIdx = nextArgs.indexOf("-vf")
if (vfIdx >= 0 && typeof nextArgs[vfIdx + 1] === "string") {
// 内存守护:高风险场景先去掉 unsharp 并降级 scale flags减少 wasm 压力。
let optimizedVf = nextArgs[vfIdx + 1].replace(
/(?:^|,)unsharp=[^,]*/g,
""
)
if (filterGuardLevel >= 2) {
const lightScaleFlag =
file.size >= 40 * 1024 * 1024 ? "bilinear" : "bicubic"
optimizedVf = optimizedVf.replace(
/:flags=[^,']+/g,
`:flags=${lightScaleFlag}`
)
}
optimizedVf = optimizedVf
.replace(/,,+/g, ",")
.replace(/^,|,$/g, "")
nextArgs[vfIdx + 1] = optimizedVf
}
ctx.postMessage({
type: "LOG",
message: `[oom-guard] apply level=${filterGuardLevel} reason=${
isRawH264 ? "raw-h264" : "large-file"
}`,
})
return nextArgs
})()
const findArgValue = (flag: string) => {
const idx = runtimeArgs.indexOf(flag)
if (idx < 0) return null
const value = runtimeArgs[idx + 1]
return typeof value === "string" ? value : null
}
const parseFps = () => {
const vf = findArgValue("-vf")
if (!vf) return null
const match = vf.match(/(?:^|,)fps=(\d+(?:\.\d+)?)(?:,|$)/)
if (!match) return null
const fps = Number(match[1])
return Number.isFinite(fps) && fps > 0 ? fps : null
}
const parseScaleWidthFromFilter = () => {
const vf = findArgValue("-vf")
if (!vf) return null
const minMatch = vf.match(/min\(\s*(\d+)\s*,\s*iw\s*\)/i)
if (minMatch) {
const parsed = Number(minMatch[1])
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
const fixedMatch = vf.match(
/scale\s*=\s*'?\s*(\d+)\s*:\s*-?\d+(?::[^,'\s]+=[^,'\s]+)*\s*'?/i
)
if (!fixedMatch) return null
const parsed = Number(fixedMatch[1])
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
const outputExt = (() => {
for (let i = runtimeArgs.length - 1; i >= 0; i -= 1) {
const v = runtimeArgs[i]
if (typeof v !== "string") continue
const m = v.match(/\.([a-zA-Z0-9]+)$/)
if (m) return m[1].toLowerCase()
}
return "jpg"
})()
let quickFirstFrameSucceeded = false
if (quickFirstFrame) {
metrics.mode = "first-frame-fast"
try {
const requestedScaleWidth = parseScaleWidthFromFilter() ?? 640
const fastWidth = Math.max(
320,
Math.min(requestedScaleWidth, 640)
)
const vf = findArgValue("-vf")
const qv = findArgValue("-q:v")
const fastQ = Number.isFinite(Number(qv))
? String(Math.max(Number(qv), 4))
: "4"
const fastFilter = (() => {
const baseFilter =
vf
?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "")
.replace(/^,|,$/g, "")
.replace(/,,+/g, ",") ?? ""
let filter = baseFilter || `scale='min(${fastWidth},iw)':-2`
filter = filter.replace(
/min\(\s*\d+\s*,\s*iw\s*\)/g,
`min(${fastWidth},iw)`
)
filter = filter.replace(/:flags=[^,']+/g, ":flags=bilinear")
if (!/:flags=/.test(filter)) filter = `${filter}:flags=bilinear`
if (!/(?:^|,)format=/.test(filter))
filter = `${filter},format=yuv420p`
return filter
})()
const fastOutputName = `frame_fast_000001.${outputExt}`
const fastArgs = [
"-ss",
"0",
"-i",
inputPath,
"-threads",
"1",
"-an",
"-sn",
"-dn",
"-vf",
fastFilter,
"-q:v",
fastQ,
"-pix_fmt",
"yuv420p",
"-frames:v",
"1",
fastOutputName,
]
ctx.postMessage({
type: "LOG",
message: `[mode] quick-first-frame width=${fastWidth} q=${fastQ}`,
})
const execStartedAt = nowMs()
const ret = await ffmpeg.exec(fastArgs)
metrics.execMs += nowMs() - execStartedAt
if (ret !== 0) {
throw new Error(`FAST_FIRST_FRAME_RET_${ret}`)
}
const files = await ffmpeg.listDir(".")
const imageFiles = files.filter(
(f) =>
!f.isDir &&
f.name.startsWith("frame_") &&
f.name.endsWith(`.${outputExt}`)
)
const targetName = imageFiles.find(
(f) => f.name === fastOutputName
)?.name
? fastOutputName
: imageFiles[0]?.name
if (!targetName) {
throw new Error("NO_FAST_FIRST_FRAME")
}
const readStartedAt = nowMs()
const raw = (await ffmpeg.readFile(targetName)) as Uint8Array
const copy = raw.slice()
metrics.readMs += nowMs() - readStartedAt
metrics.frameCount = 1
quickFirstFrameSucceeded = true
await deleteFileQuietly(targetName)
for (const imageFile of imageFiles) {
if (imageFile.name === targetName) continue
await deleteFileQuietly(imageFile.name)
}
ctx.postMessage(
{
type: "SEGMENT_DATA",
files: [{ name: targetName, data: copy }],
},
[copy.buffer]
)
} catch (e) {
const quickError = normalizeError(e)
if (!continueAfterQuickFirstFrame) throw e
ctx.postMessage({
type: "LOG",
message: `[mode] quick-first-frame failed, fallback to full extraction: ${quickError}`,
})
}
if (!continueAfterQuickFirstFrame) {
ctx.postMessage({ type: "PROGRESS", progress: 1 })
const elapsedMs = nowMs() - metrics.startedAtMs
ctx.postMessage({
type: "LOG",
message: `[metrics] phase=done mode=${metrics.mode} frames=1 totalMs=${Math.round(
elapsedMs
)} execMs=${Math.round(metrics.execMs)} readMs=${Math.round(
metrics.readMs
)}`,
})
ctx.postMessage({ type: "DONE" })
return
}
if (quickFirstFrameSucceeded) {
ctx.postMessage({ type: "PROGRESS", progress: 0.01 })
ctx.postMessage({
type: "LOG",
message:
"[mode] quick-first-frame done; continue full extraction in same worker",
})
}
}
let durationSec = 0
const skipDurationProbe = Boolean(
quickFirstFrame && continueAfterQuickFirstFrame
)
const durationProbePath = "__duration.txt"
if (!skipDurationProbe) {
try {
await ffmpeg.ffprobe([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
inputPath,
"-o",
durationProbePath,
])
const txt = (await ffmpeg.readFile(
durationProbePath,
"utf8"
)) as string
const parsed = Number.parseFloat(String(txt).trim())
if (Number.isFinite(parsed) && parsed > 0) durationSec = parsed
} catch (e) {
ctx.postMessage({
type: "LOG",
message: `[probe] ffprobe failed: ${normalizeError(e)}`,
})
} finally {
await deleteFileQuietly(durationProbePath)
}
if (!durationSec) {
const logHandler = ({ message }: { message: string }) => {
const match = message.match(
/Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/
)
if (match) {
const [, h, m, s] = match
durationSec =
parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s)
}
}
ffmpeg.on("log", logHandler)
try {
await ffmpeg.exec(["-i", inputPath])
} catch (e) {
void e
} finally {
ffmpeg.off("log", logHandler)
}
}
} else {
ctx.postMessage({
type: "LOG",
message:
"[probe] skip duration probe after quick-first-frame to reduce second-frame latency",
})
}
ctx.postMessage({
type: "LOG",
message: `[probe] durationSec=${durationSec || "unknown"}`,
})
if (!durationSec) durationSec = 600
const fps = parseFps() ?? 1
const stepSec = 1 / fps
const streamStartSec = quickFirstFrameSucceeded ? stepSec : 0
const preferSingleFrame = file.size >= 20 * 1024 * 1024
const maxFrames = preferSingleFrame ? 30 : 0
const reloadEvery = preferSingleFrame ? 10 : 0
let sentBytes = 0
const requestedScaleWidth = parseScaleWidthFromFilter() ?? 720
const shouldDownscaleForSafety =
filterGuardLevel >= 2 && requestedScaleWidth > 640
const maxWidth = shouldDownscaleForSafety
? Math.max(360, Math.round(requestedScaleWidth * 0.78))
: requestedScaleWidth
ctx.postMessage({
type: "LOG",
message: `[config] fileMB=${Math.round(fileMB)} fps=${fps} requestedWidth=${requestedScaleWidth} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame} filterGuardLevel=${filterGuardLevel} streamStartSec=${streamStartSec}`,
})
ctx.postMessage({
type: "LOG",
message: `[metrics] phase=start fileMB=${Math.round(fileMB)} durationSec=${durationSec} fps=${fps} requestedWidth=${requestedScaleWidth} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame} streamStartSec=${streamStartSec}`,
})
const buildSingleFrameArgs = (
outputPattern: string,
startNumber: number,
useBasicFilter = false
) => {
const vf = findArgValue("-vf")
const qv = findArgValue("-q:v")
const out: string[] = []
if (useBasicFilter) {
const fallbackWidth = Math.max(240, Math.min(maxWidth, 640))
out.push(
"-vf",
`scale='min(${fallbackWidth},iw)':-2:flags=bicubic,format=yuv420p`
)
} else {
const baseFilter =
vf
?.replace(/(?:^|,)fps=\d+(?:\.\d+)?(?=,|$)/g, "")
.replace(/^,|,$/g, "")
.replace(/,,+/g, ",") ?? null
const patchedFilter = (() => {
if (!baseFilter || !baseFilter.trim()) return null
let f = baseFilter
f = f.replace(
/min\(\s*\d+\s*,\s*iw\s*\)/g,
`min(${maxWidth},iw)`
)
f = f.replace(
/scale\s*=\s*'?\s*\d+\s*:\s*(-?\d+)((?::[^,'\s]+=[^,'\s]+)*)\s*'?/g,
(_match, h: string, opts?: string) =>
`scale='min(${maxWidth},iw)':${h}${opts || ""}`
)
if (!/(?:^|,)format=/.test(f)) f = `${f},format=yuv420p`
return f
})()
if (patchedFilter && patchedFilter.trim())
out.push("-vf", patchedFilter)
}
if (qv) {
const qNum = Number(qv)
if (Number.isFinite(qNum) && preferSingleFrame) {
out.push("-q:v", String(Math.max(qNum, useBasicFilter ? 4 : 3)))
} else {
out.push("-q:v", qv)
}
}
out.push("-pix_fmt", "yuv420p")
out.push("-start_number", String(startNumber))
out.push("-frames:v", "1", outputPattern)
return out
}
const segmentCandidates = preferSingleFrame
? [2, 3, 5].filter((v) => v >= stepSec && v > 0)
: [2, 1.2, 1].filter((v) => v >= stepSec && v > 0)
const trySegmentDuration = async (
segmentDurationSec: number,
frameLimit = 0
) => {
// 主路径:按时间片执行 ffmpeg分批读取并回传帧控制内存峰值。
metrics.mode = "segment"
metrics.segmentDurationSec = segmentDurationSec
let t = streamStartSec
let emptyStreak = 0
let producedAny = false
let producedFrames = 0
while (t < durationSec) {
logThrottled(
`[exec] mode=segment t=${t.toFixed(3)} dur=${segmentDurationSec}`
)
const segmentArgs = [
"-ss",
t.toString(),
"-t",
segmentDurationSec.toString(),
"-i",
inputPath,
"-threads",
"1",
"-an",
"-sn",
"-dn",
...runtimeArgs,
]
const execStartedAt = nowMs()
const ret = await ffmpeg.exec(segmentArgs)
metrics.execMs += nowMs() - execStartedAt
if (ret !== 0) {
logThrottled(
`[exec] mode=segment ret=${ret} t=${t.toFixed(3)} dur=${segmentDurationSec}`
)
}
const files = await ffmpeg.listDir(".")
const imageFiles = files.filter(
(f) =>
!f.isDir &&
f.name.startsWith("frame_") &&
f.name.endsWith(`.${outputExt}`)
)
if (imageFiles.length === 0) {
emptyStreak += 1
if (emptyStreak >= Math.ceil(5 / segmentDurationSec)) break
} else {
emptyStreak = 0
producedAny = true
}
const segmentFiles: { name: string; data: Uint8Array }[] = []
const transfer: ArrayBuffer[] = []
const readStartedAt = nowMs()
const remaining =
frameLimit > 0 ? Math.max(frameLimit - producedFrames, 0) : 0
for (let i = 0; i < imageFiles.length; i += 1) {
const f = imageFiles[i]
if (frameLimit > 0 && i >= remaining) {
await deleteFileQuietly(f.name)
continue
}
const raw = (await ffmpeg.readFile(f.name)) as Uint8Array
const copy = raw.slice()
segmentFiles.push({
name: `t_${t.toFixed(3)}_${f.name}`,
data: copy,
})
sentBytes += copy.byteLength
transfer.push(copy.buffer)
await deleteFileQuietly(f.name)
}
metrics.readMs += nowMs() - readStartedAt
if (segmentFiles.length > 0) {
metrics.frameCount += segmentFiles.length
producedFrames += segmentFiles.length
// 通过 transferable 传输二进制,避免主线程和 worker 双份拷贝。
ctx.postMessage(
{ type: "SEGMENT_DATA", files: segmentFiles },
transfer
)
}
ctx.postMessage({
type: "PROGRESS",
progress: Math.min((t + segmentDurationSec) / durationSec, 1),
})
if (frameLimit > 0 && producedFrames >= frameLimit) break
t += segmentDurationSec
}
if (!producedAny) {
throw new Error("NO_FRAMES")
}
}
const runSingleFrameMode = async (useBasicFilter = false) => {
// 回退路径:逐秒/逐帧抽样,牺牲吞吐换稳定性。
metrics.mode = useBasicFilter ? "single-fallback" : "single"
let index = streamStartSec > 0 ? 1 : 0
let emptyStreak = 0
let producedCount = 0
const pattern = `frame_%06d.${outputExt}`
const reloadCore = async (reason: string) => {
if (!lastBaseURL) return
metrics.reloadCount += 1
ctx.postMessage({ type: "LOG", message: `[reload] ${reason}` })
// 长任务周期性重载 core缓解 wasm 内存碎片化。
await unmountInput()
ffmpeg.terminate()
loaded = false
await ffmpeg.load({
coreURL: `${lastBaseURL}/ffmpeg-core.js`,
wasmURL: `${lastBaseURL}/ffmpeg-core.wasm`,
})
loaded = true
await mountInput()
}
for (let t = streamStartSec; t < durationSec; t += stepSec) {
if (maxFrames > 0 && index >= maxFrames) break
if (reloadEvery > 0 && index > 0 && index % reloadEvery === 0) {
await reloadCore(`periodic frame=${index}`)
}
const outputName = `frame_${String(index).padStart(6, "0")}.${outputExt}`
const singleFrameArgs = [
"-ss",
t.toString(),
"-i",
inputPath,
"-threads",
"1",
"-an",
"-sn",
"-dn",
...(buildSingleFrameArgs(pattern, index, useBasicFilter) || []),
]
try {
const execStartedAt = nowMs()
const ret = await ffmpeg.exec(singleFrameArgs)
metrics.execMs += nowMs() - execStartedAt
ctx.postMessage({
type: "LOG",
message: `[exec] mode=single ret=${ret} t=${t.toFixed(3)} out=${outputName}`,
})
} catch (e) {
const msg = normalizeError(e)
if (isOom(msg)) throw e
ctx.postMessage({
type: "LOG",
message: `[exec] mode=single error t=${t.toFixed(3)}: ${msg}`,
})
if (
msg.includes("At least one output file must be specified")
) {
ctx.postMessage({
type: "LOG",
message: `[exec] mode=single argsTail=${JSON.stringify(
singleFrameArgs.slice(-12)
)}`,
})
}
await deleteFileQuietly(outputName)
index += 1
continue
}
try {
const readStartedAt = nowMs()
const raw = (await ffmpeg.readFile(outputName)) as Uint8Array
const copy = raw.slice()
metrics.readMs += nowMs() - readStartedAt
sentBytes += copy.byteLength
metrics.frameCount += 1
producedCount += 1
await deleteFileQuietly(outputName)
ctx.postMessage(
{
type: "SEGMENT_DATA",
files: [{ name: outputName, data: copy }],
},
[copy.buffer]
)
logThrottled(
`[frame] idx=${index} t=${t.toFixed(3)} size=${copy.byteLength} sentMB=${Math.round((sentBytes / 1024 / 1024) * 10) / 10}`
)
} catch (e) {
void e
emptyStreak += 1
if (emptyStreak >= 3) break
index += 1
continue
}
emptyStreak = 0
ctx.postMessage({
type: "PROGRESS",
progress: Math.min((t + stepSec) / durationSec, 1),
})
index += 1
}
if (producedCount === 0) {
throw new Error(
useBasicFilter
? "NO_FRAMES_SINGLE_FALLBACK"
: "NO_FRAMES_SINGLE"
)
}
}
let segmentOk = false
for (const seg of segmentCandidates) {
metrics.segmentAttempts += 1
try {
ctx.postMessage({
type: "LOG",
message: `[mode] trying segmentDuration=${seg}s stepSec=${stepSec}s`,
})
await trySegmentDuration(seg, maxFrames)
segmentOk = true
break
} catch (e) {
const msg = normalizeError(e)
if (msg.includes("NO_FRAMES")) break
if (
msg.includes("Output file is empty") ||
msg.includes("nothing was encoded")
) {
break
}
if (!isOom(msg)) throw e
}
}
if (!segmentOk) {
metrics.fallbackToSingle = segmentCandidates.length > 0
ctx.postMessage({
type: "LOG",
message: `[mode] segment mode unavailable; fallback to single-frame sampling`,
})
try {
await runSingleFrameMode()
} catch (e) {
const msg = normalizeError(e)
if (!msg.includes("NO_FRAMES_SINGLE")) throw e
ctx.postMessage({
type: "LOG",
message:
"[mode] single mode produced no frames; retry with basic filter",
})
await runSingleFrameMode(true)
}
}
const elapsedMs = nowMs() - metrics.startedAtMs
const sentMB = Math.round((sentBytes / 1024 / 1024) * 100) / 100
const avgFrameKB = metrics.frameCount
? Math.round((sentBytes / metrics.frameCount / 1024) * 10) / 10
: 0
const throughputFps =
elapsedMs > 0
? Math.round((metrics.frameCount / (elapsedMs / 1000)) * 100) /
100
: 0
ctx.postMessage({
type: "LOG",
message: `[metrics] phase=done mode=${metrics.mode} frames=${metrics.frameCount} sentMB=${sentMB} avgFrameKB=${avgFrameKB} totalMs=${Math.round(
elapsedMs
)} execMs=${Math.round(metrics.execMs)} readMs=${Math.round(
metrics.readMs
)} throughputFps=${throughputFps} segmentAttempts=${
metrics.segmentAttempts
} segmentDurationSec=${metrics.segmentDurationSec} fallbackToSingle=${
metrics.fallbackToSingle
} reloadCount=${metrics.reloadCount}`,
})
// 所有帧都已通过 SEGMENT_DATA 流式回传,这里只发完成信号。
ctx.postMessage({ type: "DONE" })
} catch (e: any) {
const errorMessage =
typeof e === "string" ? e : e?.message || String(e)
if (errorMessage.includes("memory access out of bounds")) {
try {
ffmpeg.terminate()
loaded = false
ctx.postMessage({
type: "LOG",
message: `[fatal] ffmpeg terminated due to: ${errorMessage}`,
})
if (lastBaseURL) {
await ffmpeg.load({
coreURL: `${lastBaseURL}/ffmpeg-core.js`,
wasmURL: `${lastBaseURL}/ffmpeg-core.wasm`,
})
loaded = true
ctx.postMessage({
type: "LOG",
message: `[fatal] ffmpeg reloaded`,
})
}
} catch (e2) {
let flag = typeof e2 === "string"
ctx.postMessage({
type: "LOG",
message: `[fatal] reload failed: ${flag ? e2 : (e2 as any)?.message || String(e2)}`,
})
}
ctx.postMessage({ type: "FATAL", error: errorMessage })
return
}
throw e
} finally {
// 始终清理挂载目录,避免残留状态影响下一次任务。
try {
await unmountInput()
} catch (e) {
ctx.postMessage({
type: "LOG",
message: `[cleanup] ${typeof e === "string" ? e : (e as any)?.message || String(e)}`,
})
}
}
break
}
} catch (error: any) {
const msg =
typeof error === "string" ? error : error?.message || String(error)
ctx.postMessage({ type: "ERROR", error: msg })
}
}

View File

@@ -0,0 +1,339 @@
"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}`
)
}
}
}