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