402 lines
12 KiB
TypeScript
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)':-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>
|
|
)
|
|
}
|