32blogby StudioMitsu

Procesa video en el navegador con ffmpeg.wasm

Ejecuta FFmpeg completamente en el navegador sin servidor usando ffmpeg.wasm. Configuración, headers SharedArrayBuffer/COOP/COEP y una implementación completa en React/Next.js.

10 min read
FFmpegWebAssemblyffmpeg.wasmbrowserfrontend
Contenido

¿Y si pudieras convertir archivos de video completamente en el navegador, sin ningún servidor involucrado? Sin tiempos de espera de carga. Sin costos de infraestructura. Sin que los archivos del usuario abandonen nunca el dispositivo.

ffmpeg.wasm hace esto posible. Es FFmpeg compilado a WebAssembly, y se ejecuta completamente en el lado del cliente. En este artículo iremos desde la instalación hasta un componente funcional de conversión de archivos en React/Next.js.


Procesamiento de video sin servidor

Las herramientas tradicionales de conversión de video basadas en navegador funcionan así:

  1. El usuario sube un archivo de video al servidor
  2. El servidor ejecuta FFmpeg y convierte el archivo
  3. El usuario descarga el resultado convertido

Este enfoque tiene dos problemas: es lento (los archivos se transfieren dos veces) y cuesta dinero (CPU del servidor). Para archivos grandes, los usuarios pueden esperar minutos solo por el viaje de ida y vuelta.

ffmpeg.wasm es el código fuente en C de FFmpeg compilado a WebAssembly via Emscripten. Se ejecuta dentro del motor JavaScript del navegador. Todo el procesamiento ocurre en el cliente — nunca se transfiere ningún archivo a un servidor.

Ventajas principales:

  • No requiere infraestructura de servidor (puro frontend)
  • Los archivos nunca abandonan el dispositivo del usuario — fuerte garantía de privacidad
  • Funciona sin conexión después de que el binario WASM se cachea
  • Costo de escalado cero

Desventajas realistas:

  • El binario WASM tarda unos segundos en descargarse en la primera carga
  • La velocidad de procesamiento es más lenta que FFmpeg nativo (a menudo 5–20x más lento)
  • Los archivos grandes pueden alcanzar los límites de memoria y fallar

Para archivos de tamaño pequeño a mediano en escenarios de conversión práctica, la compensación vale la pena.


Configuración y uso básico

Instala los paquetes:

bash
npm install @ffmpeg/ffmpeg @ffmpeg/util

Aquí está el patrón de uso básico:

ts
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";

const ffmpeg = new FFmpeg();

async function convertVideo(inputFile: File): Promise<Blob> {
  // Load the WASM binary (only on first call)
  if (!ffmpeg.loaded) {
    const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";
    await ffmpeg.load({
      coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
      wasmURL: await toBlobURL(
        `${baseURL}/ffmpeg-core.wasm`,
        "application/wasm"
      ),
    });
  }

  // Write the input file into ffmpeg.wasm's virtual filesystem
  await ffmpeg.writeFile("input.mp4", await fetchFile(inputFile));

  // Run the FFmpeg command (MP4 → WebM conversion)
  await ffmpeg.exec(["-i", "input.mp4", "-c:v", "libvpx-vp9", "output.webm"]);

  // Read the output file back
  const data = await ffmpeg.readFile("output.webm");

  // Convert Uint8Array → Blob and return
  return new Blob([data], { type: "video/webm" });
}

El array que pasas a ffmpeg.exec() se mapea directamente a los argumentos del comando FFmpeg después del nombre del binario ffmpeg. ffmpeg -i input.mp4 output.webm se convierte en ["-i", "input.mp4", "output.webm"].


El muro de SharedArrayBuffer (Headers COOP/COEP)

Para habilitar SharedArrayBuffer, tu servidor debe responder con dos headers HTTP:

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

Para Next.js, agrega esto a next.config.ts:

ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        // Apply to all routes. Narrow the source path if you only
        // need ffmpeg.wasm on specific pages.
        source: "/(.*)",
        headers: [
          {
            key: "Cross-Origin-Opener-Policy",
            value: "same-origin",
          },
          {
            key: "Cross-Origin-Embedder-Policy",
            value: "require-corp",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

Efectos secundarios a tener en cuenta:

COOP/COEP restringen la carga de recursos de origen cruzado. Si tu sitio carga fuentes externas, scripts de Google Analytics u otros assets de terceros, esos necesitarán atributos crossorigin="anonymous" o un header Cross-Origin-Resource-Policy en el servidor del recurso. La mayoría de los CDN importantes ya envían este header, pero puede causar roturas inesperadas si no lo has considerado.

Si usas la compilación de un solo hilo (@ffmpeg/core en lugar de @ffmpeg/core-mt), SharedArrayBuffer no es necesario y puedes omitir los headers COOP/COEP — a costa de un procesamiento más lento ya que no se usarán múltiples núcleos de CPU. Empezar con la compilación de un solo hilo es la forma más fácil de ponerse en marcha.

Al desplegar en Vercel, la configuración de headers en next.config.ts se respeta automáticamente — no se necesita configuración adicional específica de Vercel.


Ejemplos de conversión de video

Aquí hay ejemplos prácticos de conversión para casos de uso comunes.

MP4 → WebM (VP9, optimizado para tamaño de archivo):

ts
await ffmpeg.exec([
  "-i",
  "input.mp4",
  "-c:v",
  "libvpx-vp9",
  "-crf",
  "33", // Quality (lower = higher quality, higher = smaller file)
  "-b:v",
  "0", // VBR mode (used together with -crf)
  "-c:a",
  "libopus",
  "output.webm",
]);

MP4 → GIF (primeros 5 segundos):

ts
await ffmpeg.exec([
  "-i",
  "input.mp4",
  "-t",
  "5", // Stop after 5 seconds
  "-vf",
  "fps=15,scale=480:-1:flags=lanczos", // Frame rate and resize
  "-loop",
  "0",
  "output.gif",
]);

Extraer audio de video (MP3):

ts
await ffmpeg.exec([
  "-i",
  "input.mp4",
  "-vn", // Discard video stream
  "-c:a",
  "libmp3lame",
  "-q:a",
  "2", // Quality (0-9, lower = higher quality)
  "output.mp3",
]);

Para seguir el progreso, registra un listener con ffmpeg.on("progress", callback) antes de llamar a exec:

ts
ffmpeg.on("progress", ({ progress, time }) => {
  // progress: 0.0 to 1.0
  // time: current position in the video being processed (microseconds)
  console.log(`Progress: ${Math.round(progress * 100)}%`);
});

Construir un componente React/Next.js

Aquí hay un componente completo de conversión de archivos donde los usuarios pueden soltar un archivo y recibir una descarga convertida.

tsx
"use client";

import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
import { useRef, useState } from "react";

const CORE_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";

export function VideoConverter() {
  const ffmpegRef = useRef<FFmpeg>(new FFmpeg());
  const [loaded, setLoaded] = useState(false);
  const [loading, setLoading] = useState(false);
  const [progress, setProgress] = useState<number | null>(null);
  const [outputUrl, setOutputUrl] = useState<string | null>(null);

  async function loadFFmpeg() {
    if (loaded) return;
    setLoading(true);
    const ffmpeg = ffmpegRef.current;

    await ffmpeg.load({
      coreURL: await toBlobURL(
        `${CORE_BASE_URL}/ffmpeg-core.js`,
        "text/javascript"
      ),
      wasmURL: await toBlobURL(
        `${CORE_BASE_URL}/ffmpeg-core.wasm`,
        "application/wasm"
      ),
    });

    ffmpeg.on("progress", ({ progress }) => {
      setProgress(Math.round(progress * 100));
    });

    setLoaded(true);
    setLoading(false);
  }

  async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    await loadFFmpeg();

    const ffmpeg = ffmpegRef.current;
    setProgress(0);
    setOutputUrl(null);

    // Write the input file to the virtual filesystem
    await ffmpeg.writeFile("input.mp4", await fetchFile(file));

    // Run conversion
    await ffmpeg.exec(["-i", "input.mp4", "-c:v", "libvpx-vp9", "-crf", "33", "-b:v", "0", "output.webm"]);

    // Read output and create a download URL
    const data = await ffmpeg.readFile("output.webm");
    const blob = new Blob([data as Uint8Array], { type: "video/webm" });
    const url = URL.createObjectURL(blob);
    setOutputUrl(url);
    setProgress(null);
  }

  return (
    <div className="space-y-4 p-6 border rounded-lg">
      <h2 className="text-xl font-bold">Video Converter (MP4 → WebM)</h2>

      <input
        type="file"
        accept="video/mp4"
        onChange={handleFileChange}
        disabled={loading || progress !== null}
        className="block"
      />

      {loading && (
        <p className="text-sm text-muted-foreground">Loading FFmpeg...</p>
      )}

      {progress !== null && (
        <div>
          <div className="h-2 bg-muted rounded-full overflow-hidden">
            <div
              className="h-full bg-primary transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="text-sm mt-1">{progress}%</p>
        </div>
      )}

      {outputUrl && (
        <div className="space-y-2">
          <video src={outputUrl} controls className="w-full max-w-lg" />
          <a
            href={outputUrl}
            download="output.webm"
            className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded"
          >
            Download
          </a>
        </div>
      )}
    </div>
  );
}

Algunas cosas a destacar sobre esta implementación. La instancia de FFmpeg vive en un useRef — no en useState — porque no quieres que React la recree en cada renderizado. Cargar el binario WASM es costoso, así que quieres hacerlo una vez y reutilizar la misma instancia.

La llamada a URL.createObjectURL crea una URL de Blob que debería revocarse cuando ya no se necesite. Para código de producción, llama a URL.revokeObjectURL(url) en una función de limpieza de useEffect para evitar fugas de memoria.


Limitaciones y rendimiento

Velocidad de procesamiento:

ffmpeg.wasm es notablemente más lento que FFmpeg nativo. Un benchmark aproximado: convertir un MP4 de 5 minutos a WebM que toma 30 segundos de forma nativa podría tomar 3–10 minutos en el navegador, dependiendo de la CPU del dispositivo. Este es un costo inherente del sandbox WASM.

Disponibilidad de códecs:

La compilación por defecto de ffmpeg.wasm incluye un conjunto limitado de códecs. Los códecs con complicaciones de patentes — como la codificación H.265 (HEVC) o la codificación AV1 — pueden no estar disponibles en la compilación estándar. Los casos de uso comunes (decodificación H.264, codificación VP9, conversión de formato) funcionan bien.

Compatibilidad de navegadores:

Cualquier navegador moderno que soporte WebAssembly y SharedArrayBuffer funcionará: Chrome 92+, Firefox 79+, Safari 15.2+. IE no está soportado, pero en 2026 eso rara vez es una preocupación.

Modelo de hilos:

ffmpeg.wasm usa Web Workers internamente, así que las conversiones no bloquean el hilo principal — tu interfaz se mantiene responsiva durante el procesamiento. El callback de progreso se dispara desde dentro del Worker, así que puedes manejar una barra de progreso sin ninguna lógica extra de hilos de tu lado.


Conclusión

ffmpeg.wasm abre la puerta a herramientas de procesamiento de video sin servidor construidas completamente en el frontend.

Puntos clave:

  • Usa la API v0.12+: Importa la clase FFmpeg e instancia con new FFmpeg() — no la antigua factory createFFmpeg()
  • Configura los headers COOP/COEP: El aislamiento de origen cruzado es necesario para SharedArrayBuffer; configúralo en next.config.ts antes de hacer cualquier otra cosa
  • Protege contra archivos grandes: Los archivos de más de 1 GB pueden agotar la memoria del navegador — agrega una verificación de tamaño en tu interfaz
  • Mantén la instancia de FFmpeg en un ref: Usa useRef para mantenerla entre renderizados y evitar pagar el costo de carga WASM más de una vez
  • Establece expectativas sobre la velocidad: El procesamiento es más lento que nativo; una barra de progreso hace la espera más llevadera

Para conversión de archivos de tamaño pequeño a mediano, generación de GIF y extracción de audio, ffmpeg.wasm entrega un rendimiento práctico. Si estás construyendo una herramienta centrada en la privacidad donde enviar archivos a un servidor no es una opción, o un servicio donde quieres cero costo de infraestructura por conversión, ffmpeg.wasm es una de las herramientas más interesantes del ecosistema frontend ahora mismo.