import { ActionIcon, Alert, Box, Button, Card, Container, FileInput, 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" 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 } } }, []) 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} ))} )}
) }