¿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í:
- El usuario sube un archivo de video al servidor
- El servidor ejecuta FFmpeg y convierte el archivo
- 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:
npm install @ffmpeg/ffmpeg @ffmpeg/util
Aquí está el patrón de uso básico:
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-originCross-Origin-Embedder-Policy: require-corp
Para Next.js, agrega esto a next.config.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):
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):
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):
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:
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.
"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
FFmpege instancia connew FFmpeg()— no la antigua factorycreateFFmpeg() - Configura los headers COOP/COEP: El aislamiento de origen cruzado es necesario para SharedArrayBuffer; configúralo en
next.config.tsantes 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
useRefpara 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.