Extrae miniaturas de videos en navegadores con ffmpeg.wasm
Selecciona un video local, introduce tiempos en segundos y muestra vistas previas PNG sin subir el video. Esta guía crea una pequeña aplicación de navegador con ffmpeg.wasm y Vite, con un selector de archivos, recursos Wasm compatibles, mensajes de error y limpieza entre ejecuciones.
¿Por qué extraer miniaturas en el navegador?
Una vista previa local permite elegir un fotograma útil antes de decidir si subir un video. En este ejemplo, el navegador descarga el código de la aplicación y el núcleo de FFmpeg desde tu servidor local; el video seleccionado y las imágenes generadas permanecen en la memoria del navegador. Esto describe el comportamiento de esta aplicación, no una garantía de privacidad para todas las aplicaciones que usan ffmpeg.wasm.
Conoce ffmpeg.wasm
El wrapper de ffmpeg.wasm ejecuta FFmpeg en su propio web worker. Copias un archivo en su sistema de archivos virtual, ejecutas un comando conocido de FFmpeg y lees el resultado. El núcleo de un solo hilo que se usa aquí también se ejecuta fuera del hilo principal del navegador.
Para empezar, usa un MP4 H.264 corto y sin cifrar. Este ejemplo limita las entradas a 25 MiB y las solicitudes a seis tiempos. Son límites de la demo, no una garantía de que todos los dispositivos puedan procesar un archivo así. El entorno verificado es Linux, Node.js 24.15.0, Yarn 4.12.0 y Chromium 145.0.7632.6. Node compila y sirve la aplicación; el navegador realiza el procesamiento multimedia. Otros navegadores y dispositivos móviles requieren sus propias pruebas.
Instala e inicializa
Con Node.js 24.15.0 y Corepack disponibles, pega lo siguiente en una shell compatible con Bash, en un
directorio de pruebas. Crea un proyecto nuevo llamado wasm-thumbnails y se niega a
sobrescribir un directorio existente. La subshell mantiene sin cambios el directorio actual y las
opciones de tu shell. El archivo de bloqueo local y la configuración de TypeScript mantienen el
proyecto separado de la configuración de cualquier espacio de trabajo que lo contenga.
(
set -eu
mkdir wasm-thumbnails
cd wasm-thumbnails
cat > package.json <<'JSON'
{
"name": "wasm-thumbnails",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@ffmpeg/core": "0.12.10",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"vite": "8.3.1"
}
}
JSON
printf '{"compilerOptions":{"target":"ES2022"}}\n' > tsconfig.json
touch yarn.lock
YARN_NODE_LINKER=node-modules corepack yarn install
)
Conserva el archivo yarn.lock generado para resolver las dependencias de forma
reproducible. Guarda los siguientes tres archivos dentro de wasm-thumbnails. Primero,
copy-core.ts copia el núcleo ESM del paquete instalado para que los archivos
JavaScript y Wasm coincidan. La guía de carga oficial especifica
recursos ESM para Vite; los recursos del mismo origen se pueden cargar directamente sin wrappers de
URL de blob.
import { copyFile, mkdir } from 'node:fs/promises'
await mkdir('public/core', { recursive: true })
for (const name of ['ffmpeg-core.js', 'ffmpeg-core.wasm']) {
await copyFile(`node_modules/@ffmpeg/core/dist/esm/${name}`, `public/core/${name}`)
}
A continuación, guarda index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Local video thumbnails</title>
<link rel="icon" href="data:," />
<style>
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 1rem; }
label { display: block; margin-block: 1rem; }
input { max-width: 100%; }
figure { margin-inline: 0; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<h1>Local video thumbnails</h1>
<form id="picker">
<fieldset id="controls">
<legend>Choose a video and timestamps</legend>
<label>Video <input id="video" type="file" accept="video/*" required /></label>
<label>Seconds, separated by commas
<input id="times" type="text" value="0.5, 1.5" required />
</label>
<button type="submit">Extract thumbnails</button>
</fieldset>
</form>
<p id="status" role="status">Choose a video to begin.</p>
<section id="results" aria-label="Thumbnails"></section>
<script type="module" src="/main.ts"></script>
</body>
</html>
Cumple los requisitos del navegador
Sirve la aplicación a través de localhost como se muestra a continuación, en lugar de abrir
index.html como archivo. Este @ffmpeg/core de un solo hilo
funciona sin SharedArrayBuffer ni encabezados COOP/COEP. El wrapper sigue necesitando
workers de tipo módulo y WebAssembly. La descarga del núcleo ocupa unos 31 MiB antes de la compresión
HTTP, por lo que la primera extracción puede tardar notablemente más que las siguientes.
Cambiar a @ffmpeg/core-mt requiere una integración independiente: el
ejemplo de carga con múltiples hilos
requiere un recurso worker adicional y las condiciones de seguridad de
SharedArrayBuffer, incluido el aislamiento entre orígenes. No añadas esos requisitos a
este ejemplo de un solo hilo de forma predeterminada. El uso de HTTPS en producción, la política de
seguridad de contenido y el alojamiento bajo una subruta requieren una configuración aparte; esta
guía sirve la aplicación desde la raíz del origen.
Extrae una sola miniatura
Guarda este programa completo como main.ts.
extractThumbnail() selecciona el primer flujo de video, se desplaza al tiempo solicitado y
escribe un PNG. Cada invocación usa nombres únicos para los archivos virtuales, comprueba el estado
de salida y los bytes del resultado, y después elimina sus archivos incluso si falla.
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
let extracting = false
async function extractThumbnail(videoFile: File, seconds: number): Promise<Blob> {
if (!Number.isFinite(seconds) || seconds < 0) throw new Error('Invalid timestamp')
if (extracting) throw new Error('A thumbnail operation is already running')
extracting = true
const id = crypto.randomUUID()
const input = `input-${id}.mp4`
const output = `thumb-${id}.png`
try {
if (!ffmpeg.loaded) {
await ffmpeg.load({
coreURL: new URL('/core/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/core/ffmpeg-core.wasm', location.href).href,
})
}
await ffmpeg.writeFile(input, await fetchFile(videoFile))
const code = await ffmpeg.exec([
'-xerror', '-ss', String(seconds), '-i', input,
'-map', '0:v:0', '-frames:v', '1', '-vf', 'scale=320:-1', output,
])
if (code !== 0) throw new Error('FFmpeg could not extract this frame')
// A seek past the end can succeed without producing a usable image.
const data = await ffmpeg.readFile(output)
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('No thumbnail was produced')
}
return new Blob([new Uint8Array(data)], { type: 'image/png' })
} finally {
// A failed command may never have created one or both files.
await Promise.allSettled([ffmpeg.deleteFile(input), ffmpeg.deleteFile(output)])
extracting = false
}
}
function parseTimes(value: string): number[] {
const parts = value.split(',').map((part) => part.trim())
if (parts.length > 6 || parts.some((part) => !/^\d+(\.\d+)?$/.test(part))) {
throw new Error('Enter one to six nonnegative timestamps in seconds.')
}
const times = parts.map(Number)
if (times.some((time) => !Number.isFinite(time))) throw new Error('Invalid timestamp')
return times
}
const form = document.getElementById('picker')
const controls = document.getElementById('controls')
const video = document.getElementById('video')
const times = document.getElementById('times')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(form instanceof HTMLFormElement) || !(controls instanceof HTMLFieldSetElement) ||
!(video instanceof HTMLInputElement) || !(times instanceof HTMLInputElement) ||
!status || !results) {
throw new Error('Missing page controls')
}
let running = false
const previewUrls: string[] = []
function clearPreviews(container: HTMLElement): void {
container.replaceChildren()
for (const url of previewUrls) URL.revokeObjectURL(url)
previewUrls.length = 0
}
form.addEventListener('change', () => {
if (running) return
clearPreviews(results)
status.textContent = 'Ready to extract.'
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (running) return
const file = video.files?.[0]
if (!file) return
running = true
controls.disabled = true
clearPreviews(results)
status.textContent = 'Loading FFmpeg and extracting…'
const deadline = setTimeout(() => ffmpeg.terminate(), 60_000)
try {
if (file.size === 0 || file.size > 25 * 1024 * 1024) {
throw new Error('Choose a nonempty video of at most 25 MiB.')
}
const marks = parseTimes(times.value)
const figures = []
for (const mark of marks) {
const blob = await extractThumbnail(file, mark)
const url = URL.createObjectURL(blob)
previewUrls.push(url)
const image = new Image()
image.alt = `Video frame at ${mark} seconds`
image.src = url
await image.decode()
const caption = document.createElement('figcaption')
caption.textContent = `${mark} s`
const figure = document.createElement('figure')
figure.append(image, caption)
figures.push(figure)
}
results.replaceChildren(...figures)
status.textContent = `${marks.length} thumbnail(s) ready.`
} catch {
clearPreviews(results)
ffmpeg.terminate()
status.textContent = 'Could not extract thumbnails. Use a small valid video and times within its duration, then retry.'
} finally {
clearTimeout(deadline)
controls.disabled = false
running = false
}
})
La opción -ss se desplaza antes de
decodificar, y el desplazamiento preciso predeterminado de FFmpeg descarta los fotogramas anteriores
a la posición solicitada al transcodificar. Los fotogramas de video corresponden a instantes
discretos: un tiempo entre fotogramas no crea un fotograma interpolado. La salida tiene 320 píxeles
de ancho. Esta herramienta extrae vistas previas, no verifica la integridad de todo el archivo;
extraer correctamente una miniatura del inicio no demuestra que los paquetes posteriores del video
estén intactos.
Obtén varias miniaturas de forma secuencial
El bucle del formulario espera a que se procese cada tiempo en una única instancia de FFmpeg. Vuelve a copiar la entrada para cada fotograma, priorizando una función de extracción pequeña y autónoma frente a una API por lotes más elaborada. Los resultados aparecen juntos solo después de que se decodifiquen todas las imágenes solicitadas. Un nuevo envío borra el conjunto anterior; un lote fallido no deja vistas previas parciales.
Ejecuta lo siguiente desde el directorio que contiene wasm-thumbnails. Al copiar el
núcleo solo se sobrescriben los dos recursos del proyecto, y Vite reemplaza la compilación
dist del proyecto. Cada paso se ejecuta solo si el anterior termina
correctamente:
(
cd wasm-thumbnails &&
node copy-core.ts &&
corepack yarn vite build &&
corepack yarn vite preview --host 127.0.0.1 --port 4173 --strictPort
)
Abre http://127.0.0.1:4173, elige un video de más de 2 segundos, deja los tiempos en
0.5, 1.5 y pulsa Extract thumbnails.
Deberían aparecer dos imágenes con sus tiempos solicitados debajo y
2 thumbnail(s) ready. encima.
Usa un solo valor, como 0.5, para obtener una miniatura. Detén el servidor
con Ctrl+C; si el puerto 4173 está ocupado, elige otro puerto en el comando y en la URL. El
servidor de vista previa de Vite sirve para comprobar una compilación
local.
Mantén la interfaz ágil con un web worker
El wrapper ya gestiona el worker. El formulario desactiva el selector de archivos, el campo de entrada de tiempos y el botón de envío mientras se ejecuta el procesamiento, e ignora los envíos duplicados. No ofrece cancelación ni permite que una nueva selección se adelante a un lote activo. El límite de 60 segundos termina el worker si la carga o la extracción se atascan; el siguiente envío carga una nueva instancia del núcleo.
Consejos de rendimiento
El núcleo se carga con el primer envío y se reutiliza tras los lotes que terminan correctamente. Los archivos virtuales temporales se eliminan después de cada extracción. Las URL de las vistas previas siguen siendo válidas mientras se muestran sus imágenes y luego se revocan cuando cambian las entradas o empieza otra ejecución. Recargar o cerrar la página descarta la sesión; la aplicación no guarda las miniaturas en disco.
Eliminar archivos virtuales no garantiza que el entorno de ejecución de Wasm devuelva de inmediato toda la memoria asignada al sistema operativo. Un worker puede conservar su capacidad de memoria para reutilizarla. Las imágenes de salida más pequeñas tampoco eliminan el costo de decodificar una entrada de alta resolución. Prueba archivos representativos en los dispositivos que quieras admitir antes de aumentar los límites de la demo.
Procesamiento en el navegador frente al servidor
La extracción en el navegador es útil para obtener una vista previa antes de subir el video, pero su tiempo de descarga, uso de memoria y velocidad de procesamiento dependen del dispositivo del usuario. La extracción en el servidor requiere enviar el video y planificar la capacidad de procesamiento y la retención. Puede ser adecuada para flujos de trabajo que necesitan resultados persistentes o recursos de procesamiento constantes. Ningún enfoque ofrece por sí solo compatibilidad universal con códecs ni capacidad ilimitada.
Resuelve problemas comunes
- No aparecen vistas previas: comprueba que el video no esté vacío, esté dentro del límite de
tamaño y tenga un flujo de video que se pueda decodificar. Introduce segundos decimales, como
1.25, no00:00:01.25. Un tiempo igual o posterior al final puede no producir ningún fotograma; vuelve a intentarlo con un tiempo anterior. - Falla la carga del núcleo: confirma que tanto
/core/ffmpeg-core.jscomo/core/ffmpeg-core.wasmse sirvan desde la compilación. Repite el comando de copia y compilación después de cambiar la versión del núcleo. El wrapper de JavaScript y el núcleo tienen números de versión independientes; no necesitan compartir la misma cadena de versión. - La extracción falla o alcanza el límite de tiempo: prueba con un video más corto y de menor resolución. El manejador de errores descarta las vistas previas y termina el worker para que el siguiente envío pueda empezar desde cero.
- Errores de
SharedArrayBuffer: comprueba si sustituiste por accidente el núcleo por la versión multihilo. Añadir encabezados de aislamiento no soluciona la falta de recursos en esta configuración de un solo hilo.
Ve más allá de las vistas previas locales
Si los dispositivos de destino no pueden procesar tus videos, considera un flujo de trabajo basado en subidas con el consentimiento explícito del usuario. La documentación de miniaturas de video describe la opción de Transloadit del lado del servidor. Mantén la vista previa local útil por sí sola: una extracción fallida debería permitir elegir otro archivo o tiempo sin perder el resto del trabajo.
