AI-powered FFmpeg in your browser — describe what you want, AI creates the command, then process your video.
import React, { useState, useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
interface FFmpegInsight {
command: string;
explanation: string;
outputFilename: string;
outputMimeType: string;
}
const App = () => {
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [log, setLog] = useState<string[]>(["Initializing..."]);
const [inputFile, setInputFile] = useState<File | null>(null);
const [inputUrl, setInputUrl] = useState<string | null>(null);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [outputMime, setOutputMime] = useState("video/mp4");
const [outputName, setOutputName] = useState("output.mp4");
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [command, setCommand] = useState("-ss {time} -i {input} -t 3 -c copy {output}");
const [explanation, setExplanation] = useState<string | null>(null);
const ffmpegRef = useRef(new FFmpeg());
const videoRef = useRef<HTMLVideoElement>(null);
const addLog = (msg: string) => setLog(prev => [...prev, msg]);
useEffect(() => {
const saved = tb.insight<FFmpegInsight>();
if (saved) {
setCommand(saved.command);
setExplanation(saved.explanation);
setOutputName(saved.outputFilename || "output.mp4");
setOutputMime(saved.outputMimeType || "video/mp4");
}
}, []);
useEffect(() => {
const load = async () => {
const ffmpeg = ffmpegRef.current;
ffmpeg.on("log", ({ message }) => addLog(message));
try {
addLog("Loading FFmpeg WASM...");
await ffmpeg.load({
coreURL: tb.proxy("https://unpkg.com/@ffmpeg/[email protected]/dist/esm/ffmpeg-core.js"),
wasmURL: tb.proxy("https://unpkg.com/@ffmpeg/[email protected]/dist/esm/ffmpeg-core.wasm"),
classWorkerURL: tb.proxy("https://unpkg.com/@ffmpeg/[email protected]/dist/esm/worker.js"),
});
addLog("FFmpeg loaded successfully!");
setLoaded(true);
} catch (err) {
addLog(`Error loading FFmpeg: ${err}`);
}
setLoading(false);
};
load();
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setInputFile(file);
if (file) { addLog(`Selected: ${file.name}`); setInputUrl(URL.createObjectURL(file)); }
if (outputUrl) URL.revokeObjectURL(outputUrl);
setOutputUrl(null);
};
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement>) => {
setDuration(e.currentTarget.duration);
setCurrentTime(0);
};
const handleSeek = (time: number) => {
setCurrentTime(time);
if (videoRef.current) videoRef.current.currentTime = time;
};
const parseArgs = (input: string) => {
const args: string[] = [];
let current = "", inQuotes = false, quoteChar = "";
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if ((ch === `"` || ch === `'`) && !inQuotes) { inQuotes = true; quoteChar = ch; }
else if (ch === quoteChar && inQuotes) { inQuotes = false; quoteChar = ""; }
else if (ch === " " && !inQuotes) { if (current.length) { args.push(current); current = ""; } }
else { current += ch; }
}
if (current.length) args.push(current);
return args;
};
const handleInfer = async () => {
const result = await tb.infer<FFmpegInsight>();
if (result) {
setCommand(result.command);
setExplanation(result.explanation);
setOutputName(result.outputFilename || "output.mp4");
setOutputMime(result.outputMimeType || "video/mp4");
addLog(`✦ AI: ${result.explanation}`);
}
};
const handleRun = async () => {
if (!inputFile || !loaded || !command.trim()) return;
setProcessing(true);
addLog("Processing...");
const ffmpeg = ffmpegRef.current;
try {
const inputExt = inputFile.name.split(".").pop() || "mp4";
const inputName = `input.${inputExt}`;
await ffmpeg.writeFile(inputName, await fetchFile(inputFile));
const templated = command
.replaceAll("{input}", inputName)
.replaceAll("{output}", outputName)
.replaceAll("{time}", currentTime.toFixed(2));
const args = parseArgs(templated);
addLog(`Running: ffmpeg ${args.join(" ")}`);
await ffmpeg.exec(args);
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: outputMime });
if (outputUrl) URL.revokeObjectURL(outputUrl);
setOutputUrl(URL.createObjectURL(blob));
addLog("Done!");
} catch (err) {
addLog(`Error: ${err}`);
}
setProcessing(false);
};
const handleDownload = async () => {
if (!outputUrl) return;
if (navigator.share && navigator.canShare?.({ files: [new File([], "t.mp4", { type: outputMime })] })) {
const resp = await fetch(outputUrl);
const blob = await resp.blob();
await navigator.share({ files: [new File([blob], outputName, { type: outputMime })] });
} else {
const a = document.createElement("a");
a.href = outputUrl;
a.download = outputName;
a.click();
}
};
const renderOutputPreview = () => {
if (!outputUrl) return null;
if (outputMime.startsWith("image/")) return <img src={outputUrl} alt="Output" style={{ maxWidth: "100%", borderRadius: "8px" }} />;
if (outputMime.startsWith("audio/")) return <audio controls src={outputUrl} style={{ width: "100%" }} />;
return <video controls src={outputUrl} style={{ width: "100%", borderRadius: "8px", background: "#000" }} />;
};
const ua = navigator.userAgent;
const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes('Safari')) || /; wv\)/.test(ua);
if (isWebView) return (
<div className="container animate-in">
<div className="in-app-banner">
Your in-app browser can't download files. Please open this page directly in your browser.
<button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
Copy Link
</button>
</div>
</div>
);
return (
<div className="container animate-in">
<header className="main-header">
<h1>✦ FFmpeg AI</h1>
<p className="subtitle">Describe what you want, AI generates the FFmpeg command, then process your video. Nothing leaves your device.</p>
</header>
<div className="upload-section">
<label htmlFor="file-upload"
className={`upload-btn ${inputFile ? "has-file" : ""} ${loading ? "disabled" : ""}`}>
{loading ? <span className="shimmer">Loading FFmpeg...</span> : inputFile ? `Selected: ${inputFile.name}` : "Choose Video or Audio File"}
<input id="file-upload" type="file" accept="video/*,audio/*" onChange={handleFileChange} disabled={loading} />
</label>
</div>
{inputUrl && (
<div className="page-card animate-in" style={{ cursor: "default", marginBottom: "2rem" }}>
<div className="section-label">PREVIEW</div>
<video ref={videoRef} src={inputUrl} onLoadedMetadata={handleLoadedMetadata} controls
style={{ width: "100%", display: "block", background: "#000", borderRadius: "8px" }} />
<div style={{ marginTop: "1.5rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "0.5rem", fontSize: "0.85rem" }}>
<span style={{ fontWeight: 600 }}>Scrubber</span>
<span style={{ color: "var(--text-muted)" }}>{currentTime.toFixed(2)}s</span>
</div>
<input type="range" min={0} max={duration || 100} step={0.1} value={currentTime}
onChange={e => handleSeek(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "var(--accent)" }} />
</div>
</div>
)}
{inputUrl && (
<div className="page-card animate-in" style={{ cursor: "default", marginBottom: "2rem" }}>
<div className="command-header">
<div className="section-label" style={{ marginBottom: 0 }}>COMMAND</div>
<button className="ai-btn" onClick={handleInfer} disabled={loading}>✦ Ask AI</button>
</div>
{explanation && (
<div className="ai-explanation">
<div className="ai-explanation-label">✦ AI Insight</div>
<p>{explanation}</p>
</div>
)}
<div className="command-box">
<textarea value={command} onChange={e => setCommand(e.target.value)}
rows={3} spellCheck={false}
placeholder='e.g. -ss {time} -i {input} -t 3 -c copy {output}' />
<div className="command-meta">
<div className="command-hint">
Placeholders: <code>{"{input}"}</code> <code>{"{output}"}</code> <code>{"{time}"}</code>
</div>
<div className="command-hint">Output: <code>{outputName}</code></div>
</div>
</div>
<button className="btn" onClick={handleRun}
disabled={!loaded || !inputFile || processing} style={{ width: "100%" }}>
{processing ? <span className="shimmer">Processing...</span> : "Run Command"}
</button>
</div>
)}
{outputUrl && (
<div className="page-card animate-in success-card" style={{ cursor: "default" }}>
<div className="card-label">OUTPUT READY</div>
{renderOutputPreview()}
<div style={{ marginTop: "1rem" }}>
<button className="btn" onClick={handleDownload} style={{ width: "100%" }}>
Download {outputName}
</button>
</div>
</div>
)}
<div className="log-container">
<div className="log-title">System Logs</div>
{processing && log.length > 0 && (
<div className="last-log">{log[log.length - 1]}</div>
)}
<details>
<summary>View all logs ({log.length})</summary>
<div className="log">
{log.map((line, i) => <div key={i} style={{ marginBottom: "2px" }}>{line}</div>)}
</div>
</details>
</div>
</div>
);
};
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);