Files
labelmain/app/component/label/ffmpeg/VideoFrameExtractor.tsx
2026-02-12 17:30:29 +08:00

402 lines
12 KiB
TypeScript

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)':-1",
"-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>
)
}