Files
labelmain/app/component/label/ffmpeg/ffmpeg.worker.ts
2026-02-12 17:30:29 +08:00

576 lines
18 KiB
TypeScript

// Define types for worker messages
export type WorkerMessage =
| { type: "LOAD"; baseURL: string }
| { type: "EXEC"; file: File; args: string[]; outputPattern: string }
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":
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 } = event.data
ctx.postMessage({
type: "LOG",
message: `[start] outputPattern=${outputPattern}`,
})
const mountDir = "/input_mount"
let inputPath = `${mountDir}/${file.name}`
const mountInput = async () => {
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 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 findArgValue = (flag: string) => {
const idx = args.indexOf(flag)
if (idx < 0) return null
const value = args[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
}
let durationSec = 0
const durationProbePath = "__duration.txt"
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 ffmpeg.deleteFile(durationProbePath).catch((err) => {
console.log("deleteFile error", err)
})
}
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)
}
}
ctx.postMessage({
type: "LOG",
message: `[probe] durationSec=${durationSec || "unknown"}`,
})
if (!durationSec) durationSec = 600
const fps = parseFps() ?? 1
const stepSec = 1 / fps
const preferSingleFrame = file.size >= 20 * 1024 * 1024
const maxFrames = preferSingleFrame ? 30 : 0
const reloadEvery = preferSingleFrame ? 10 : 0
let sentBytes = 0
const maxWidth =
file.size >= 40 * 1024 * 1024 ? 360 : preferSingleFrame ? 480 : 720
ctx.postMessage({
type: "LOG",
message: `[config] fileMB=${Math.round(file.size / 1024 / 1024)} fps=${fps} maxWidth=${maxWidth} preferSingleFrame=${preferSingleFrame}`,
})
const outputExt = (() => {
for (let i = args.length - 1; i >= 0; i -= 1) {
const v = args[i]
if (typeof v !== "string") continue
const m = v.match(/\.([a-zA-Z0-9]+)$/)
if (m) return m[1].toLowerCase()
}
return "jpg"
})()
const buildSingleFrameArgs = (
outputPattern: string,
startNumber: number
) => {
const vf = findArgValue("-vf")
const qv = findArgValue("-q:v")
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*720\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`)
f = f.replace(/min\(\s*480\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`)
f = f.replace(/min\(\s*360\s*,\s*iw\s*\)/g, `min(${maxWidth},iw)`)
if (!/(?:^|,)format=/.test(f)) f = `${f},format=yuv420p`
return f
})()
const out: string[] = []
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, 6)))
} 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, 1.2, 1].filter((v) => v >= stepSec && v > 0)
const trySegmentDuration = async (segmentDurationSec: number) => {
let t = 0
let emptyStreak = 0
let producedAny = false
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",
...args,
]
const ret = await ffmpeg.exec(segmentArgs)
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[] = []
for (const f of imageFiles) {
const raw = (await ffmpeg.readFile(f.name)) as Uint8Array
const copy = raw.slice()
segmentFiles.push({
name: `t_${t.toFixed(3)}_${f.name}`,
data: copy,
})
transfer.push(copy.buffer)
await ffmpeg.deleteFile(f.name)
}
if (segmentFiles.length > 0) {
ctx.postMessage(
{ type: "SEGMENT_DATA", files: segmentFiles },
transfer
)
}
ctx.postMessage({
type: "PROGRESS",
progress: Math.min((t + segmentDurationSec) / durationSec, 1),
})
t += segmentDurationSec
}
if (!producedAny) {
throw new Error("NO_FRAMES")
}
}
const runSingleFrameMode = async () => {
let index = 0
let emptyStreak = 0
const pattern = `frame_%06d.${outputExt}`
const reloadCore = async (reason: string) => {
if (!lastBaseURL) return
ctx.postMessage({ type: "LOG", message: `[reload] ${reason}` })
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 = 0; 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) || []),
]
try {
const ret = await ffmpeg.exec(singleFrameArgs)
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 ffmpeg.deleteFile(outputName).catch((err) => {
console.log("deleteFile error", err)
})
index += 1
continue
}
try {
const raw = (await ffmpeg.readFile(outputName)) as Uint8Array
const copy = raw.slice()
sentBytes += copy.byteLength
await ffmpeg.deleteFile(outputName).catch((err) => {
console.log("deleteFile error", err)
})
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
}
}
let segmentOk = false
for (const seg of segmentCandidates) {
try {
ctx.postMessage({
type: "LOG",
message: `[mode] trying segmentDuration=${seg}s stepSec=${stepSec}s`,
})
await trySegmentDuration(seg)
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) {
ctx.postMessage({
type: "LOG",
message: `[mode] segment mode unavailable; fallback to single-frame sampling`,
})
await runSingleFrameMode()
}
// Send DONE without 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 {
// Cleanup input
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 })
}
}