AI-powered FFmpeg in your browser — describe what you want, AI creates the command, then process your video.
---
format: typebulb/v1
name: ffmpeg AI
---
**code.tsx**
```tsx
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 />);
```
**styles.css**
```css
:root {
--bg: #fafafa;
--text: #1d1d1f;
--text-muted: #6e6e73;
--border: rgba(0, 0, 0, 0.08);
--card-bg: #ffffff;
--selection: rgba(0, 0, 0, 0.05);
--radius: 16px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
--accent: #1d1d1f;
--ai-bg: rgba(120, 80, 220, 0.06);
--ai-border: rgba(120, 80, 220, 0.2);
--ai-accent: #6b46c1;
}
html[data-theme="dark"] {
--bg: #1c1c1c;
--text: #f5f5f7;
--text-muted: #a1a1a6;
--border: rgba(255, 255, 255, 0.1);
--card-bg: #232323;
--selection: rgba(255, 255, 255, 0.08);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
--accent: #f5f5f7;
--ai-bg: rgba(160, 120, 255, 0.08);
--ai-border: rgba(160, 120, 255, 0.25);
--ai-accent: #b794f4;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "SF Pro Display", sans-serif;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
line-height: 1.5;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 3rem 2rem;
min-height: 100vh;
}
.main-header {
margin-bottom: 1.5rem;
text-align: center;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
letter-spacing: -0.03em;
line-height: 1.1;
}
.subtitle {
color: var(--text-muted);
font-size: 1rem;
font-weight: 400;
line-height: 1.6;
max-width: 480px;
margin: 0 auto;
}
.upload-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
margin-bottom: 2.5rem;
}
.upload-btn {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--border);
padding: 1rem 2rem;
border-radius: 14px;
cursor: pointer;
font-weight: 600;
font-size: 0.9375rem;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
letter-spacing: -0.01em;
}
.upload-btn:hover { box-shadow: var(--shadow-md); }
.upload-btn.has-file { background: var(--card-bg); color: var(--text); }
.upload-btn input { display: none; }
.upload-btn.disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; }
.section-label {
font-weight: 600;
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.btn {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 0.875rem 1.75rem;
border-radius: 12px;
font-weight: 600;
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
letter-spacing: -0.01em;
}
.btn:hover { box-shadow: var(--shadow-md); }
.btn:disabled { cursor: not-allowed; transform: none !important; box-shadow: var(--shadow-sm) !important; }
.page-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.75rem;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-md);
}
.success-card { box-shadow: var(--shadow-lg); }
.card-label {
font-weight: 600;
margin-bottom: 1.25rem;
font-size: 0.75rem;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
}
/* Command Section */
.command-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.ai-btn {
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--ai-bg);
color: var(--ai-accent);
border: 1px solid var(--ai-border);
border-radius: 10px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: -0.01em;
box-shadow: none;
}
.ai-btn:hover:not(:disabled) { background: var(--ai-border); box-shadow: var(--shadow-sm); }
.ai-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ai-explanation {
background: var(--ai-bg);
border: 1px solid var(--ai-border);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.ai-explanation-label {
font-weight: 700;
font-size: 0.8rem;
color: var(--ai-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.ai-explanation p {
margin: 0;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text);
}
.command-box {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.command-box textarea {
width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.85rem;
padding: 0.75rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
}
.command-box textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--selection);
}
.command-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.command-hint {
font-size: 0.8rem;
color: var(--text-muted);
}
.command-hint code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
background: var(--selection);
padding: 3px 6px;
border-radius: 6px;
border: 1px solid var(--border);
}
/* Range */
input[type="range"] {
-webkit-appearance: none;
height: 4px;
background: var(--selection);
border-radius: 3px;
outline: none;
transition: background 0.2s;
}
input[type="range"]:hover { background: var(--border); }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--text);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--text);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
}
input[type="range"]::-moz-range-thumb:hover { transform: scale(1.15); }
/* Shimmer */
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.shimmer {
background: linear-gradient(90deg, var(--text-muted) 25%, var(--text) 50%, var(--text-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Log Panel */
.log-container {
margin-top: 3rem;
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
background: var(--card-bg);
transition: all 0.2s;
}
.log-container:hover { border-color: var(--text-muted); }
.log-title {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-muted);
letter-spacing: 0.01em;
margin-bottom: 0.75rem;
}
.last-log {
font-size: 0.8rem;
color: var(--text-muted);
font-family: "SF Mono", "Monaco", "Consolas", monospace;
margin-bottom: 0.75rem;
}
.log-container summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.01em;
user-select: none;
}
.log {
background: var(--selection);
padding: 1rem;
border-radius: 8px;
margin-top: 0.75rem;
max-height: 200px;
overflow-y: auto;
font-family: "SF Mono", "Monaco", "Consolas", monospace;
font-size: 0.75rem;
line-height: 1.6;
}
/* Animations */
.in-app-banner {
background: var(--selection);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
color: var(--text);
line-height: 1.5;
text-align: center;
}
.copy-link-btn {
display: block;
margin: 0.75rem auto 0;
padding: 0.5rem 1rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
}
.copy-link-btn:hover { box-shadow: var(--shadow-sm); }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
video { display: block; }
@media (max-width: 640px) {
.container { padding: 2rem 1.25rem; }
h1 { font-size: 2rem; }
.subtitle { font-size: 0.9375rem; }
.page-card { padding: 1.25rem; }
.command-meta { flex-direction: column; align-items: flex-start; }
}
```
**index.html**
```html
<div id="root"></div>
```
**data.txt**
```txt
Extract the audio track from your video and convert it to a high-quality MP3 file. The video stream is discarded, and the audio is encoded at variable bitrate quality level 2 (~190 kbps).
```
**infer.md**
```md
# FFmpeg Command Generator
You are generating an FFmpeg command based on a user's natural language description of what they want to do with their video or audio file. The command will run in FFmpeg WASM (v0.12, based on FFmpeg 6.0) inside a web browser.
## Your Task
Read the user's description and produce a single JSON object with the correct FFmpeg command and metadata.
## Placeholders (REQUIRED)
You MUST use these placeholders in the command — they are replaced at runtime:
- `{input}` → the input filename (e.g., `input.mp4`)
- `{output}` → the output filename (e.g., `output.mp4`)
- `{time}` → the user's current video scrubber position in seconds (e.g., `14.50`)
## Rules
1. Do NOT include `ffmpeg` at the start — output only the arguments
2. Always use `{input}` for the input file and `{output}` for the output file
3. Use `{time}` when the operation involves a specific timestamp (trim from current position, snapshot at current time, etc.)
4. Choose the most appropriate output format for the user's request
5. Prefer `-c copy` (stream copy) when no re-encoding is needed (simple trims, muxing changes)
6. When video filters (`-vf`) are used, re-encoding is required — use `libx264` + `aac` for mp4
7. For GIF output, a simple approach like `-vf "fps=10,scale=480:-1"` works well
8. For audio extraction, use `-vn` to strip video
9. For frame extraction as image, use `-frames:v 1`
## Available Codecs & Formats
- **Video codecs**: libx264, libvpx, libvpx-vp9, mpeg4
- **Audio codecs**: aac, libmp3lame, libvorbis, pcm_s16le
- **Output formats**: mp4, webm, gif, mp3, wav, ogg, avi, mkv, flac, png, jpg
- **Filters**: Most standard filters work — scale, crop, rotate, transpose, fps, fade, reverse, hflip, vflip, eq, colorbalance, setpts, atempo, volume, loudnorm, etc.
## Constraints
- Single input file, single output file only
- No network or streaming URLs
- No hardware acceleration flags
- Complex filter_complex graphs work but may be slow
- Very large files may cause browser memory issues
## Output JSON
Return a JSON object with exactly these four fields:
- `command` — FFmpeg arguments string using `{input}`, `{output}`, and optionally `{time}` placeholders
- `explanation` — A friendly 1–3 sentence explanation of what the command does, understandable to someone who doesn't know FFmpeg
- `outputFilename` — The output filename with the correct extension (e.g., `output.mp4`, `output.gif`, `output.mp3`, `output.png`)
- `outputMimeType` — The MIME type of the output (e.g., `video/mp4`, `image/gif`, `audio/mpeg`, `image/png`)
## Important
- Return ONLY the JSON object — no markdown fences, no commentary, no extra text
- If the user's request is ambiguous, make reasonable assumptions and mention them in the explanation
- If the user mentions "current time" or "current position", use the `{time}` placeholder
- Always produce a valid, runnable FFmpeg command
```
**insight.json**
```json
{
"command": "-i {input} -vn -acodec libmp3lame -q:a 2 {output}",
"explanation": "Extracts the audio track from your video and converts it to a high-quality MP3 file. The video stream is discarded, and the audio is encoded at variable bitrate quality level 2 (~190 kbps).",
"outputFilename": "output.mp3",
"outputMimeType": "audio/mpeg"
}
```
**config.json**
```json
{
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2"
},
"description": "AI-powered FFmpeg in your browser — describe what you want, AI creates the command, then process your video.",
"inference": {
"title": "AI FFmpeg Command Generator",
"dataTitle": "What do you want to do with your video?",
"submitTitle": "Generate Command"
}
}
```