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(null) const [processing, setProcessing] = useState(false) const [progress, setProgress] = useState(0) const [message, setMessage] = useState("Wait for loading...") const [error, setError] = useState(null) const [images, setImages] = useState<{ url: string; name: string }[]>([]) const [logs, setLogs] = useState([]) const workerRef = useRef(null) const messageRef = useRef(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) => { 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 ( Video Frame Extractor (Worker + Local WASM) Extract frames from your video files purely in the browser using FFmpeg.wasm (Async Worker). {error && ( } title="Error" color="red" withCloseButton onClose={() => setError(null)}> {error} )} } value={file} onChange={setFile} disabled={!loaded || processing} /> {!loaded && isLoading && ( Loading FFmpeg core... )} {loaded && ( )} {processing && ( {message} )} {logs.length > 0 && ( Logs {logs.map((log, i) => (
{log}
))}
)} {images.length > 0 && ( Extracted Frames ({images.length}) {images.map((img) => ( {img.name} {img.name} ))} )}
) }