Files
labelmain-demo/app/component/label/ffmpeg/ffmpeg.worker.ts
2026-03-09 10:01:31 +08:00

926 lines
32 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.

// 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 })
}
}