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