ffmpeg AI

AI-powered FFmpeg in your browser — describe what you want, AI creates the command, then process your video.

code.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 />);

Markdown source · More bulbs by samples · Typebulb home