---
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/core@0.12.6/dist/esm/ffmpeg-core.js"),
          wasmURL: tb.proxy("https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm/ffmpeg-core.wasm"),
          classWorkerURL: tb.proxy("https://unpkg.com/@ffmpeg/ffmpeg@0.12.15/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"
  }
}
```