521 lines
16 KiB
TypeScript
521 lines
16 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"
|
|
const 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(() => {})
|
|
await ffmpeg.deleteDir(mountDir).catch(() => {})
|
|
}
|
|
|
|
await mountInput()
|
|
|
|
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(() => {})
|
|
}
|
|
|
|
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(() => {})
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const raw = (await ffmpeg.readFile(outputName)) as Uint8Array
|
|
const copy = raw.slice()
|
|
sentBytes += copy.byteLength
|
|
await ffmpeg.deleteFile(outputName).catch(() => {})
|
|
|
|
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) {
|
|
ctx.postMessage({
|
|
type: "LOG",
|
|
message: `[fatal] reload failed: ${
|
|
typeof e2 === "string"
|
|
? 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 })
|
|
}
|
|
}
|