926 lines
32 KiB
TypeScript
926 lines
32 KiB
TypeScript
// 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 })
|
||
}
|
||
}
|