feat(label): add label page

This commit is contained in:
2026-03-10 18:20:50 +08:00
parent 1ae8509cce
commit 014c34ab76
65 changed files with 23477 additions and 0 deletions

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