perf(component): perf
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentBaseDepartmentPage() {
|
||||
return <>ComponentBaseDepartmentPage</>
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Divider,
|
||||
Paper,
|
||||
Stack,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
|
||||
export default function ComponentBasePlacementPage() {
|
||||
const items = [{ title: "布局组件" }].map((item) => (
|
||||
<UnstyledButton key={item.title}>{item.title}</UnstyledButton>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md">
|
||||
<Breadcrumbs>{items}</Breadcrumbs>
|
||||
<Paper shadow="xs" p="0" flex={1} display="flex">
|
||||
<Stack flex={1}>
|
||||
<Stack flex={1} justify="center" align="center">
|
||||
<Text c="dimmed">空</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Divider orientation="vertical"></Divider>
|
||||
<Stack flex={2}></Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import TreeSelect from "@/components/tree-select"
|
||||
import { getCityTree } from "@/components/tree-select/api"
|
||||
import { TreeNode } from "@/components/tree-select/api/type"
|
||||
import { VirtualTreeSelect } from "@/components/tree-select/tree"
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Flex,
|
||||
Paper,
|
||||
Stack,
|
||||
TreeNodeData,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
const convertTreeNodeToTreeNodeData = (
|
||||
list: TreeNode[],
|
||||
level: number = 1
|
||||
): TreeNodeData[] =>
|
||||
list.map((g) => ({
|
||||
label: g.label,
|
||||
value: g.value,
|
||||
nodeProps: { level: level },
|
||||
children:
|
||||
g.children && g.children.length
|
||||
? convertTreeNodeToTreeNodeData(g.children, level + 1)
|
||||
: [],
|
||||
}))
|
||||
|
||||
export default function ComponentBaseSelectPage() {
|
||||
const items = [{ title: "选择组件" }].map((item) => (
|
||||
<UnstyledButton key={item.title}>{item.title}</UnstyledButton>
|
||||
))
|
||||
|
||||
const [value, setValue] = useState<string>("")
|
||||
|
||||
const [treeData, setTreeData] = useState<TreeNodeData[]>([])
|
||||
const asyncGetCityTree = useCallback(async () => {
|
||||
const res = await getCityTree()
|
||||
setTreeData(convertTreeNodeToTreeNodeData(res))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => asyncGetCityTree())
|
||||
}, [asyncGetCityTree])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md">
|
||||
<Breadcrumbs>{items}</Breadcrumbs>
|
||||
<Paper shadow="xs" p={"md"} flex={1} display="flex">
|
||||
<Flex h={"fit-content"} gap={"lg"}>
|
||||
<TreeSelect treeData={treeData} />
|
||||
|
||||
<VirtualTreeSelect
|
||||
data={treeData}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
setCurrCityCode={() => {}}
|
||||
w={"200px"}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex>{value}</Flex>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
# Video Frame Extractor (FFmpeg.wasm)
|
||||
|
||||
## 运行位置
|
||||
- 纯前端:所有转码/抽帧在浏览器 Web Worker + WASM 内完成,不依赖后端服务。
|
||||
|
||||
## 大文件最佳实践
|
||||
- 优先使用 WORKERFS 挂载输入文件,避免 `writeFile(arrayBuffer)` 将整段视频复制进 WASM 内存。
|
||||
- 抽帧建议走“单帧模式”:每次 `-ss <time> -frames:v 1` 只产出一张,读取后立即删除,避免在 WASM 内累积。
|
||||
- 输出建议使用 image2 序列 pattern(例如 `frame_%06d.jpg`)并配合 `-start_number`,避免 image2 的“必须是序列 pattern”报错路径。
|
||||
- UI 端必须对日志与图片列表做上限与释放(`URL.revokeObjectURL`),否则长视频会导致 JS 堆持续增长。
|
||||
|
||||
## 故障恢复
|
||||
- 一旦出现 `Aborted()` / `memory access out of bounds`,当前 FFmpeg.wasm 实例可能进入不可恢复状态,需要 terminate 并重建实例/worker。
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
FileInput,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core"
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDownload,
|
||||
IconMovie,
|
||||
IconPhoto,
|
||||
} from "@tabler/icons-react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { appendCapped } from "./cap"
|
||||
import type { WorkerResponse } from "./ffmpeg.worker"
|
||||
import { getServerImage } from "@/components/label/api/label"
|
||||
|
||||
export default function VideoFrameExtractor() {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [message, setMessage] = useState("Wait for loading...")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [images, setImages] = useState<{ url: string; name: string }[]>([])
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
const messageRef = useRef<HTMLDivElement>(null)
|
||||
const extractedCountRef = useRef(0)
|
||||
|
||||
const MAX_LOG_LINES = 300
|
||||
const MAX_IMAGES = 1500
|
||||
|
||||
// Initialize Worker once
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!workerRef.current) {
|
||||
setIsLoading(true)
|
||||
|
||||
const baseURL = window.location.origin + "/wasm"
|
||||
|
||||
const createWorker = () => {
|
||||
const worker = new Worker(
|
||||
new URL("./ffmpeg.worker.ts", import.meta.url)
|
||||
)
|
||||
workerRef.current = worker
|
||||
return worker
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const { type } = event.data
|
||||
|
||||
switch (type) {
|
||||
case "READY":
|
||||
setLoaded(true)
|
||||
setIsLoading(false)
|
||||
setMessage("FFmpeg loaded successfully")
|
||||
break
|
||||
case "LOG":
|
||||
if ("message" in event.data) {
|
||||
const msg = event.data.message
|
||||
setLogs((prev) => appendCapped(prev, [msg], MAX_LOG_LINES))
|
||||
if (messageRef.current) {
|
||||
messageRef.current.scrollTop = messageRef.current.scrollHeight
|
||||
}
|
||||
}
|
||||
break
|
||||
case "PROGRESS":
|
||||
if ("progress" in event.data) {
|
||||
const p = Math.round(event.data.progress * 100)
|
||||
setProgress(p)
|
||||
setMessage(`Processing... ${p}%`)
|
||||
}
|
||||
break
|
||||
case "SEGMENT_DATA":
|
||||
if ("files" in event.data) {
|
||||
const newImages = event.data.files.map((f: any) => {
|
||||
const blob = new Blob([f.data], { type: "image/jpeg" })
|
||||
return { url: URL.createObjectURL(blob), name: f.name }
|
||||
})
|
||||
extractedCountRef.current += newImages.length
|
||||
setImages((prev) =>
|
||||
appendCapped(prev, newImages, MAX_IMAGES, (img) =>
|
||||
URL.revokeObjectURL((img as any).url)
|
||||
)
|
||||
)
|
||||
setMessage(
|
||||
`Processing... Extracted ${extractedCountRef.current} frames.`
|
||||
)
|
||||
}
|
||||
break
|
||||
case "DONE":
|
||||
setMessage(
|
||||
`Completed! Extracted ${extractedCountRef.current} frames.`
|
||||
)
|
||||
setProcessing(false)
|
||||
break
|
||||
case "FATAL":
|
||||
if ("error" in event.data) {
|
||||
setError(event.data.error)
|
||||
setProcessing(false)
|
||||
setLoaded(false)
|
||||
setIsLoading(true)
|
||||
}
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate()
|
||||
workerRef.current = null
|
||||
}
|
||||
{
|
||||
const worker = createWorker()
|
||||
worker.onmessage = handleMessage
|
||||
worker.postMessage({ type: "LOAD", baseURL })
|
||||
}
|
||||
break
|
||||
case "ERROR":
|
||||
if ("error" in event.data) {
|
||||
setError(event.data.error)
|
||||
setProcessing(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const worker = createWorker()
|
||||
worker.onmessage = handleMessage
|
||||
|
||||
worker.postMessage({ type: "LOAD", baseURL })
|
||||
}
|
||||
})
|
||||
// Only create worker if not exists
|
||||
|
||||
// Cleanup worker on unmount?
|
||||
// Usually good practice, but if we want to keep it alive for navigation back/forth,
|
||||
// we might want to move it to Context.
|
||||
// For this task, local cleanup is fine.
|
||||
return () => {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate()
|
||||
workerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 将视频 Base64 转换为 File 对象
|
||||
* @param {string} base64String - 视频 Base64 字符串
|
||||
* @param {string} fileName - 文件名 (例如 'video.mp4')
|
||||
* @returns {File}
|
||||
*/
|
||||
function videoBase64ToFile(base64String: string, fileName: string) {
|
||||
// 1. 提取 MIME 类型和数据
|
||||
const parts = base64String.split(",")
|
||||
const mime = parts[0].match(/:(.*?);/)?.[1] // 可能是 video/mp4, video/webm 等
|
||||
|
||||
// 2. 解码
|
||||
const bstr = atob(parts[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
|
||||
// 3. 填充字节数组
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n)
|
||||
}
|
||||
|
||||
// 4. 返回 File 对象
|
||||
return new File([u8arr], fileName, { type: mime })
|
||||
}
|
||||
|
||||
const getServerVideo = async () => {
|
||||
const params = {
|
||||
data_names: ["6874b86e341d1d0e4c75d0d5.h264"],
|
||||
data_type: 0,
|
||||
project_id: 9,
|
||||
}
|
||||
let text
|
||||
const response = await getServerImage(params)
|
||||
text = `data:video/h264;base64,${response}`
|
||||
|
||||
const fileRes = videoBase64ToFile(text, "6874b86e341d1d0e4c75d0d5.h264")
|
||||
|
||||
// const videoFile = new File([response], "6874b86e341d1d0e4c75d0d5.264", {
|
||||
// type: "video/264",
|
||||
// })
|
||||
setFile(fileRes)
|
||||
}
|
||||
|
||||
function downloadFile(file: File | null) {
|
||||
if (!file) return
|
||||
// 创建一个临时的 DOM URL 指向内存中的文件
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
// 创建一个隐藏的 a 标签
|
||||
const a = document.createElement("a")
|
||||
a.style.display = "none"
|
||||
a.href = url
|
||||
a.download = file.name // 设置下载后的文件名
|
||||
|
||||
// 将标签添加到文档中并触发点击
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
|
||||
// 清理工作:移除标签并释放内存 URL
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const processVideo = () => {
|
||||
if (!file || !loaded || !workerRef.current) return
|
||||
|
||||
setProcessing(true)
|
||||
setProgress(0)
|
||||
extractedCountRef.current = 0
|
||||
for (const img of images) URL.revokeObjectURL(img.url)
|
||||
setImages([])
|
||||
setLogs([])
|
||||
setError(null)
|
||||
setMessage("Starting processing...")
|
||||
|
||||
// Extract 1 frame per second: -vf fps=1
|
||||
// Optimize: Scale down to 720p max width to save memory
|
||||
workerRef.current.postMessage({
|
||||
type: "EXEC",
|
||||
file,
|
||||
args: [
|
||||
"-vf",
|
||||
"fps=1,scale='min(720,iw)':-2:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.5:3:3:0",
|
||||
"-q:v",
|
||||
"2",
|
||||
"frame_%03d.jpg",
|
||||
],
|
||||
outputPattern: "frame_",
|
||||
})
|
||||
}
|
||||
|
||||
const downloadAll = () => {
|
||||
images.forEach((img, index) => {
|
||||
setTimeout(() => {
|
||||
const a = document.createElement("a")
|
||||
a.href = img.url
|
||||
a.download = img.name
|
||||
a.click()
|
||||
}, index * 200)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>Video Frame Extractor (Worker + Local WASM)</Title>
|
||||
<Text c="dimmed">
|
||||
Extract frames from your video files purely in the browser using
|
||||
FFmpeg.wasm (Async Worker).
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Error"
|
||||
color="red"
|
||||
withCloseButton
|
||||
onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card withBorder shadow="sm" p="lg" radius="md">
|
||||
<Flex gap={"md"}>
|
||||
<Button onClick={getServerVideo}>getServerVideo</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
downloadFile(file)
|
||||
}}>
|
||||
downloadFile
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Stack gap="md">
|
||||
<FileInput
|
||||
label="Select Video File"
|
||||
placeholder="Click to select MP4, WebM..."
|
||||
// accept="video/*"
|
||||
leftSection={<IconMovie size={16} />}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
disabled={!loaded || processing}
|
||||
/>
|
||||
|
||||
{!loaded && isLoading && (
|
||||
<Group>
|
||||
<Loader size="sm" />
|
||||
<Text size="sm">Loading FFmpeg core...</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{loaded && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={processVideo}
|
||||
disabled={!file || processing}
|
||||
loading={processing}
|
||||
leftSection={<IconPhoto size={16} />}>
|
||||
{processing ? "Processing..." : "Start Extraction"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{processing && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Progress value={progress} animated />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<Card withBorder shadow="sm" p="xs" radius="md" bg="gray.0">
|
||||
<Text size="xs" fw={500} mb="xs">
|
||||
Logs
|
||||
</Text>
|
||||
<ScrollArea h={100} type="always" viewportRef={messageRef}>
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "11px",
|
||||
}}>
|
||||
{logs.map((log, i) => (
|
||||
<div key={i}>{log}</div>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={3}>Extracted Frames ({images.length})</Title>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={downloadAll}>
|
||||
Download All
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: 4 }} spacing="md">
|
||||
{images.map((img) => (
|
||||
<Card
|
||||
key={img.name}
|
||||
shadow="sm"
|
||||
padding="xs"
|
||||
radius="md"
|
||||
withBorder>
|
||||
<Card.Section>
|
||||
<Image
|
||||
src={img.url}
|
||||
height={160}
|
||||
alt={img.name}
|
||||
fit="cover"
|
||||
/>
|
||||
</Card.Section>
|
||||
<Group justify="space-between" mt="xs" mb="xs">
|
||||
<Text fw={500} size="xs" truncate>
|
||||
{img.name}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
component="a"
|
||||
href={img.url}
|
||||
download={img.name}>
|
||||
<IconDownload size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { appendCapped } from "./cap"
|
||||
|
||||
describe("appendCapped", () => {
|
||||
test("keeps all items when under max", () => {
|
||||
expect(appendCapped([1, 2], [3], 10)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("drops from head when exceeding max", () => {
|
||||
expect(appendCapped([1, 2], [3, 4, 5], 3)).toEqual([3, 4, 5])
|
||||
})
|
||||
|
||||
test("calls onDrop for dropped items", () => {
|
||||
const dropped: number[] = []
|
||||
const result = appendCapped([1, 2], [3, 4, 5], 3, (x) => dropped.push(x))
|
||||
expect(result).toEqual([3, 4, 5])
|
||||
expect(dropped).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
export const appendCapped = <T>(
|
||||
prev: T[],
|
||||
next: T[],
|
||||
max: number,
|
||||
onDrop?: (item: T) => void
|
||||
) => {
|
||||
const merged = [...prev, ...next]
|
||||
if (merged.length <= max) return merged
|
||||
const dropCount = merged.length - max
|
||||
if (onDrop) {
|
||||
for (let i = 0; i < dropCount; i += 1) onDrop(merged[i])
|
||||
}
|
||||
return merged.slice(dropCount)
|
||||
}
|
||||
@@ -1,925 +0,0 @@
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import VideoFrameExtractor from "./VideoFrameExtractor"
|
||||
|
||||
export default function ComponentLabelFfmpegPage() {
|
||||
return <VideoFrameExtractor />
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { userLogin } from "@/components/label/api/label"
|
||||
import { usePermissionStore } from "@/components/label/store/auth"
|
||||
import { Button } from "@mantine/core"
|
||||
|
||||
export default function ComponentLabelLoginPage() {
|
||||
const { setUserInfo, setUserPassword } = usePermissionStore()
|
||||
const user_id = usePermissionStore.getState().user_id
|
||||
|
||||
// 用户登录
|
||||
const handleUserLoginBtnClick = async () => {
|
||||
try {
|
||||
const res = await userLogin({
|
||||
name: "admin",
|
||||
password: "123456",
|
||||
})
|
||||
const { uid, name, token, refresh_token } = res
|
||||
setUserInfo({
|
||||
user_id: uid,
|
||||
user_name: name,
|
||||
token,
|
||||
refresh_token,
|
||||
detailInfo: res,
|
||||
})
|
||||
setUserPassword("123456")
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleUserLoginBtnClick}>Login</Button>
|
||||
user_id:{user_id}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import LabelContent from "@/components/label"
|
||||
import { useAppLayoutStore } from "@/components/layout/store"
|
||||
import { Breadcrumbs, Flex, Paper, Stack, UnstyledButton } from "@mantine/core"
|
||||
import { useMemo } from "react"
|
||||
|
||||
export default function ComponentLabelPicturePage() {
|
||||
const items = [{ title: "地图展示" }].map((item) => (
|
||||
<UnstyledButton key={item.title}>{item.title}</UnstyledButton>
|
||||
))
|
||||
const { isOpen } = useAppLayoutStore()
|
||||
let leftWidth = useMemo(() => {
|
||||
let width = 69
|
||||
return !isOpen ? width : width + 160
|
||||
}, [isOpen])
|
||||
return (
|
||||
<>
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p={"md"}>
|
||||
<Flex justify={"space-between"} align={"center"} h={"16px"}>
|
||||
<Breadcrumbs>{items}</Breadcrumbs>
|
||||
</Flex>
|
||||
<Paper
|
||||
shadow="xs"
|
||||
p="0"
|
||||
flex={1}
|
||||
display="flex"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<LabelContent
|
||||
headerHeight={128}
|
||||
leftWidth={leftWidth}
|
||||
project_id={1}
|
||||
task_id={3}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import LabelContent from "@/components/label"
|
||||
|
||||
export default function ComponentLabelPictureStandalonePage() {
|
||||
return (
|
||||
<>
|
||||
<LabelContent headerHeight={0} leftWidth={0} project_id={1} task_id={3} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { WorkerMessage, WorkerResponse } from "../ffmpeg/ffmpeg.worker"
|
||||
|
||||
export interface VideoFrameFile {
|
||||
name: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
interface ProcessVideoParams {
|
||||
base64Data: string
|
||||
fileName: string
|
||||
namePrefix?: string
|
||||
baseURL?: string
|
||||
args?: string[]
|
||||
timeoutMs?: number
|
||||
firstFrameTimeoutMs?: number
|
||||
quickFirstFrame?: boolean
|
||||
continueAfterQuickFirstFrame?: boolean
|
||||
onFrames?: (frames: VideoFrameFile[]) => void
|
||||
onProgress?: (progress: number) => void
|
||||
}
|
||||
|
||||
const defaultArgs = [
|
||||
"-vf",
|
||||
// 基线策略:1fps + Lanczos 缩放 + 轻微锐化,兼顾清晰度与处理速度。
|
||||
"fps=1,scale='min(720,iw)':-2:flags=lanczos+accurate_rnd+full_chroma_int,unsharp=5:5:0.5:3:3:0",
|
||||
"-q:v",
|
||||
"2",
|
||||
"frame_%03d.jpg",
|
||||
]
|
||||
|
||||
const lowMemoryArgs = [
|
||||
"-vf",
|
||||
// OOM 回退:降低分辨率并使用更轻滤镜,优先保稳定输出。
|
||||
"fps=1,scale='min(480,iw)':-2:flags=bilinear,format=yuv420p",
|
||||
"-q:v",
|
||||
"4",
|
||||
"frame_%03d.jpg",
|
||||
]
|
||||
|
||||
const timeoutFallbackArgs = [
|
||||
"-vf",
|
||||
// 超时回退:进一步降采样,减少单次解码计算量。
|
||||
"fps=1,scale='min(360,iw)':-2:flags=bilinear,format=yuv420p",
|
||||
"-q:v",
|
||||
"5",
|
||||
"frame_%03d.jpg",
|
||||
]
|
||||
|
||||
const mimeByExt: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
mp4: "video/mp4",
|
||||
mov: "video/quicktime",
|
||||
webm: "video/webm",
|
||||
mkv: "video/x-matroska",
|
||||
avi: "video/x-msvideo",
|
||||
h264: "video/h264",
|
||||
"264": "video/h264",
|
||||
}
|
||||
|
||||
const getFileExt = (fileName: string) => {
|
||||
const segments = fileName.split(".")
|
||||
if (segments.length <= 1) return ""
|
||||
return segments[segments.length - 1].toLowerCase()
|
||||
}
|
||||
|
||||
const stripFileExt = (fileName: string) => {
|
||||
const index = fileName.lastIndexOf(".")
|
||||
if (index <= 0) return fileName
|
||||
return fileName.slice(0, index)
|
||||
}
|
||||
|
||||
const normalizeBase64 = (text: string) => {
|
||||
if (!text) return ""
|
||||
const raw = text.includes(",") ? text.split(",").slice(1).join(",") : text
|
||||
return raw.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
const sanitizePrefix = (prefix: string) => {
|
||||
const safe = prefix
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
return safe || "video"
|
||||
}
|
||||
|
||||
const uint8ArrayToBase64 = (arr: Uint8Array) => {
|
||||
const chunkSize = 0x8000
|
||||
let binary = ""
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
binary += String.fromCharCode(...arr.subarray(i, i + chunkSize))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
const base64ToFile = (base64Data: string, fileName: string) => {
|
||||
const payload = normalizeBase64(base64Data)
|
||||
if (!payload) throw new Error("empty video payload")
|
||||
const binary = atob(payload)
|
||||
const len = binary.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
const fileExt = getFileExt(fileName)
|
||||
return new File([bytes], fileName, {
|
||||
type: mimeByExt[fileExt] || "video/mp4",
|
||||
})
|
||||
}
|
||||
|
||||
const getOutputExtFromArgs = (args: string[]) => {
|
||||
for (let i = args.length - 1; i >= 0; i -= 1) {
|
||||
const ext = getFileExt(args[i])
|
||||
if (ext) return ext
|
||||
}
|
||||
return "jpg"
|
||||
}
|
||||
|
||||
export const processVideo = async (
|
||||
params: ProcessVideoParams
|
||||
): Promise<VideoFrameFile[]> => {
|
||||
if (typeof window === "undefined") return []
|
||||
|
||||
const args = params.args && params.args.length ? params.args : defaultArgs
|
||||
const fileExt = getFileExt(params.fileName)
|
||||
const prefix = sanitizePrefix(
|
||||
params.namePrefix || stripFileExt(params.fileName)
|
||||
)
|
||||
const baseURL =
|
||||
params.baseURL ||
|
||||
`${window.location.origin}${process.env.NEXT_PUBLIC_BASE_PATH}/wasm`
|
||||
const inputFile = base64ToFile(params.base64Data, params.fileName)
|
||||
const fileMB = inputFile.size / 1024 / 1024
|
||||
const inferredTimeoutMs = (() => {
|
||||
// 大文件和裸流(.h264/.264)给予更长时间,避免误判超时。
|
||||
let ms = 240_000
|
||||
if (fileMB > 30) ms += Math.round((fileMB - 30) * 2_500)
|
||||
if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 600_000)
|
||||
return Math.min(ms, 1_200_000)
|
||||
})()
|
||||
const timeoutMs = Number.isFinite(params.timeoutMs)
|
||||
? Math.max(10_000, Number(params.timeoutMs))
|
||||
: inferredTimeoutMs
|
||||
|
||||
const inferredFirstFrameTimeoutMs = (() => {
|
||||
let ms = 20_000
|
||||
if (fileMB > 30) ms += Math.round((fileMB - 30) * 1_000)
|
||||
if (fileExt === "h264" || fileExt === "264") ms = Math.max(ms, 45_000)
|
||||
return Math.min(ms, 120_000)
|
||||
})()
|
||||
const firstFrameTimeoutMs = Number.isFinite(params.firstFrameTimeoutMs)
|
||||
? Math.max(8_000, Number(params.firstFrameTimeoutMs))
|
||||
: inferredFirstFrameTimeoutMs
|
||||
|
||||
const runOnce = async (
|
||||
runArgs: string[],
|
||||
runTimeoutMs: number,
|
||||
runFirstFrameTimeoutMs: number
|
||||
) => {
|
||||
const worker = new Worker(
|
||||
new URL("../ffmpeg/ffmpeg.worker.ts", import.meta.url)
|
||||
)
|
||||
const outputExt = getOutputExtFromArgs(runArgs)
|
||||
let frameIndex = 0
|
||||
const frames: VideoFrameFile[] = []
|
||||
|
||||
try {
|
||||
return await new Promise<VideoFrameFile[]>((resolve, reject) => {
|
||||
let settled = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let firstFrameTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const finish = (next: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
if (firstFrameTimeoutId) {
|
||||
clearTimeout(firstFrameTimeoutId)
|
||||
firstFrameTimeoutId = null
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
finish(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`video process timeout after ${Math.round(runTimeoutMs / 1000)}s`
|
||||
)
|
||||
)
|
||||
)
|
||||
}, runTimeoutMs)
|
||||
firstFrameTimeoutId = setTimeout(() => {
|
||||
if (frames.length > 0) return
|
||||
finish(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`video process timeout before first frame after ${Math.round(runFirstFrameTimeoutMs / 1000)}s`
|
||||
)
|
||||
)
|
||||
)
|
||||
}, runFirstFrameTimeoutMs)
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const message = event.data
|
||||
switch (message.type) {
|
||||
case "READY": {
|
||||
// Worker 加载完 wasm 后才允许真正执行抽帧命令。
|
||||
const runMessage: WorkerMessage = {
|
||||
type: "EXEC",
|
||||
file: inputFile,
|
||||
args: runArgs,
|
||||
outputPattern: "frame_",
|
||||
quickFirstFrame: Boolean(params.quickFirstFrame),
|
||||
continueAfterQuickFirstFrame: Boolean(
|
||||
params.continueAfterQuickFirstFrame
|
||||
),
|
||||
}
|
||||
worker.postMessage(runMessage)
|
||||
return
|
||||
}
|
||||
case "SEGMENT_DATA": {
|
||||
// 分片回传:边抽边传,降低一次性内存峰值并加快首屏可见时间。
|
||||
const files = message.files || []
|
||||
const batchFrames: VideoFrameFile[] = []
|
||||
files.forEach((file) => {
|
||||
const ext = getFileExt(file.name) || outputExt || "jpg"
|
||||
const frameNo = String(frameIndex).padStart(6, "0")
|
||||
const frameName = `${prefix}_frame_${frameNo}.${ext}`
|
||||
frameIndex += 1
|
||||
const frame = {
|
||||
name: frameName,
|
||||
dataUrl: `data:${mimeByExt[ext] || "image/jpeg"};base64,${uint8ArrayToBase64(file.data)}`,
|
||||
}
|
||||
frames.push(frame)
|
||||
batchFrames.push(frame)
|
||||
})
|
||||
if (batchFrames.length) {
|
||||
if (firstFrameTimeoutId) {
|
||||
clearTimeout(firstFrameTimeoutId)
|
||||
firstFrameTimeoutId = null
|
||||
}
|
||||
try {
|
||||
params.onFrames?.(batchFrames)
|
||||
} catch (callbackError) {
|
||||
console.warn(
|
||||
"[processVideo] onFrames callback failed",
|
||||
callbackError
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case "PROGRESS": {
|
||||
try {
|
||||
params.onProgress?.(message.progress || 0)
|
||||
} catch (callbackError) {
|
||||
console.warn(
|
||||
"[processVideo] onProgress callback failed",
|
||||
callbackError
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
case "DONE": {
|
||||
finish(() => {
|
||||
if (!frames.length) {
|
||||
reject(new Error("video has no extracted frames"))
|
||||
} else {
|
||||
resolve(frames)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
case "FATAL": {
|
||||
finish(() =>
|
||||
reject(new Error(message.error || "video process fatal"))
|
||||
)
|
||||
return
|
||||
}
|
||||
case "ERROR": {
|
||||
finish(() =>
|
||||
reject(new Error(message.error || "video process error"))
|
||||
)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
worker.onerror = (event) => {
|
||||
finish(() => reject(new Error(event.message || "video worker error")))
|
||||
}
|
||||
|
||||
const loadMessage: WorkerMessage = {
|
||||
type: "LOAD",
|
||||
baseURL,
|
||||
}
|
||||
worker.postMessage(loadMessage)
|
||||
})
|
||||
} finally {
|
||||
worker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await runOnce(args, timeoutMs, firstFrameTimeoutMs)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const isOom =
|
||||
message.includes("memory access out of bounds") ||
|
||||
message.includes("Cannot enlarge memory") ||
|
||||
message.includes("Aborted")
|
||||
const isTimeout = message.includes("video process timeout")
|
||||
if (!isOom && !isTimeout) throw error
|
||||
|
||||
// 仅对可恢复错误做一次降级重试,避免无限重试拖垮页面。
|
||||
const retryArgs = isTimeout ? timeoutFallbackArgs : lowMemoryArgs
|
||||
const retryTimeoutMs = isTimeout
|
||||
? Math.max(timeoutMs, 600_000)
|
||||
: Math.max(timeoutMs, 300_000)
|
||||
const retryFirstFrameTimeoutMs = Math.max(firstFrameTimeoutMs, 35_000)
|
||||
try {
|
||||
return await runOnce(retryArgs, retryTimeoutMs, retryFirstFrameTimeoutMs)
|
||||
} catch (retryError) {
|
||||
const retryMessage =
|
||||
retryError instanceof Error ? retryError.message : String(retryError)
|
||||
throw new Error(
|
||||
`video process failed after ${isTimeout ? "timeout" : "low-memory"} retry: ${retryMessage}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import AppLayout from "@/components/layout/AppLayout"
|
||||
import { PathnameRecorder } from "@/components/layout/PathnameRecorder"
|
||||
import { componentList, showList } from "@/components/layout/common"
|
||||
|
||||
async function getMenu() {
|
||||
return [...componentList, ...showList]
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const menu = await getMenu()
|
||||
return (
|
||||
<AppLayout menu={menu}>
|
||||
{children}
|
||||
<PathnameRecorder menu={menu} />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentMediaDynamicPage() {
|
||||
return <>ComponentMediaDynamicPage</>
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentMediaStaticPage() {
|
||||
return <>ComponentMediaStaticPage</>
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function ComponentPage() {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.push("/component/base/department")
|
||||
}, [router])
|
||||
return <></>
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
"use client"
|
||||
|
||||
export default function ComponentTableBasePage() {
|
||||
return <>ComponentTableBasePage</>
|
||||
}
|
||||
Reference in New Issue
Block a user