Extrae miniaturas de video con cURL y FFmpeg
Extraer miniaturas de videos remotos puede requerir descargar archivos grandes. Si los metadatos y los fotogramas solicitados están cerca del inicio, una solicitud de rango de bytes puede reducir esa descarga. Esta guía te ofrece un script auxiliar de Bash que intenta descargar un prefijo y, si no basta, descarga el archivo completo para poder desplazarse por él. Un video local con movimiento te permite comprobar que el JPEG contiene el fotograma solicitado.
Configura un video HTTP conocido
Usa Linux con Bash, GNU coreutils, cURL 8.5 o posterior, FFmpeg con ffprobe y el codificador libx264,
y Node.js. Ubuntu 24.04 proporciona el shell y las herramientas multimedia mediante sus paquetes bash,
coreutils, curl y ffmpeg. Para el servidor de origen local
y el verificador, usa Node.js 24.15 o posterior dentro de la versión 24, o la versión 26.
Los archivos .mts siguientes se ejecutan con el soporte nativo de TypeScript
de Node y solo usan módulos integrados. No es necesario instalar un proyecto. Los binarios
oficiales de Node.js 26 para Linux también requieren el entorno de ejecución libatomic,
disponible como paquete libatomic1 de Ubuntu. El ejemplo funciona con FFmpeg 6.1.1 y
cURL 8.5.0 de Ubuntu, así como con FFmpeg 9.0.1 y cURL 8.22.0.
Pega esto en Bash desde un directorio donde no exista video-thumbnail-demo. Crea un video de
prueba de 12 segundos y 640×360 a 20 fotogramas por segundo, y una copia con sus metadatos MP4 al
final. El patrón se mueve; el cuadrado de la esquina superior izquierda es rojo antes de 1 segundo
y azul después.
(
set -euo pipefail
for tool in bash curl ffmpeg ffprobe node; do command -v "$tool" >/dev/null; done
node --version >/dev/null
curl --version >/dev/null
ffmpeg -version >/dev/null
ffprobe -version >/dev/null
mkdir -- video-thumbnail-demo || exit 1
trap 'status=$?; if [ "$status" -ne 0 ]; then
rm -f -- video-thumbnail-demo/fast.mp4 video-thumbnail-demo/tail.mp4
rmdir -- video-thumbnail-demo 2>/dev/null || true
fi' EXIT
ffmpeg -nostdin -n -v error -f lavfi \
-i "testsrc2=size=640x360:rate=20:duration=12" \
-vf "drawbox=x=0:y=0:w=80:h=80:color=red:t=fill:enable='lt(t,1)',drawbox=x=0:y=0:w=80:h=80:color=blue:t=fill:enable='gte(t,1)'" \
-c:v libx264 -preset ultrafast -crf 0 -threads 1 -filter_threads 1 \
-pix_fmt yuv420p -g 20 -movflags +faststart video-thumbnail-demo/fast.mp4
ffmpeg -nostdin -n -v error -i video-thumbnail-demo/fast.mp4 \
-map 0:v:0 -c copy video-thumbnail-demo/tail.mp4
)
Si la generación falla, el bloque elimina sus videos incompletos y su directorio. Resuelve la falta de la herramienta o del codificador y repite el bloque. Si el directorio ya existe, se conserva. Los paréntesis mantienen sin cambios las opciones del shell y tu directorio de trabajo.
Guarda lo siguiente como video-thumbnail-demo/origin.mts. Este pequeño servidor de loopback sirve los dos
archivos, respeta los rangos de prefijo y los ignora deliberadamente en /ignore.mp4.
Elige un puerto disponible y registra la URL en origin.url. Es un recurso de prueba
para este tutorial, no un servidor público.
import { rmSync, writeFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main() {
const fast = await readFile('fast.mp4')
const tail = await readFile('tail.mp4')
const files: Record<string, Buffer> = {
'/fast.mp4': fast, '/tail.mp4': tail, '/ignore.mp4': fast,
}
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
const server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://localhost').pathname
const data = files[path]
if (request.method !== 'GET' || !data) {
response.writeHead(404).end('Not found')
return
}
const range = request.headers.range
const match = range?.match(/^bytes=0-(\d+)$/)
if (range && !match && path !== '/ignore.mp4') {
response.writeHead(416).end('Use a prefix range')
return
}
const partial = match !== undefined && match !== null && path !== '/ignore.mp4'
const end = partial ? Math.min(Number(match[1]), data.length - 1) : data.length - 1
const body = data.subarray(0, end + 1)
const contentRange = partial ? `bytes 0-${end}/${data.length}` : undefined
response.setHeader('Content-Type', 'video/mp4')
response.setHeader('Content-Length', body.length)
response.setHeader('Accept-Ranges', 'bytes')
if (contentRange) response.setHeader('Content-Range', contentRange)
response.writeHead(partial ? 206 : 200).end(body)
console.log(JSON.stringify({
path, range, status: partial ? 206 : 200, bodyBytes: body.length, contentRange,
port: response.socket?.localPort,
}))
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('No TCP address')
try {
writeFileSync('origin.url', `http://127.0.0.1:${address.port}\n`, { flag: 'wx' })
} catch (error) {
server.close()
throw error
}
const stop = () => {
server.closeAllConnections()
server.close()
rmSync('origin.url', { force: true })
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
}
main().catch((error) => { console.error(error.message); process.exitCode = 1 })
Automatiza el flujo de trabajo con un script auxiliar de Bash
Guarda esto como video-thumbnail-demo/thumbnail.sh. Acepta HH:MM:SS[.ms], una tasa de bits
estimada expresada como número entero de 1 a 999 Mb/s y una ruta de salida. La entrada temporal
permite desplazarse por el video y recurrir a la descarga completa. Un fallo del decodificador o
un desplazamiento más allá de la duración del video no deben contar como éxito solo porque FFmpeg
haya devuelto cero: el script también exige una imagen recién escrita y no vacía.
#!/usr/bin/env bash
set -euo pipefail
VIDEO_URL=${1:-}
TIMESTAMP=${2:-00:00:05} # HH:MM:SS[.ms]
BITRATE_MBPS=${3:-5} # average megabits-per-second
OUT=${4:-thumbnail.jpg}
if [[ -z "$VIDEO_URL" ]]; then
echo "Usage: $0 <url> [timestamp] [bitrate_mbps] [out]" >&2
exit 1
fi
if [[ ! "$TIMESTAMP" =~ ^[0-9]{2}:[0-5][0-9]:[0-5][0-9]([.][0-9]{1,3})?$ ]] ||
[[ ! "$BITRATE_MBPS" =~ ^[1-9][0-9]{0,2}$ ]]; then
echo "Use HH:MM:SS[.ms] and a whole-number bitrate from 1 to 999 Mb/s" >&2
exit 1
fi
if [[ -e "$OUT" || -L "$OUT" ]]; then
echo "Output already exists: $OUT" >&2
exit 1
fi
IFS=: read -r H M S <<< "$TIMESTAMP"
SEEK_SECONDS=$((10#$H * 3600 + 10#$M * 60 + 10#${S%.*} + 1))
BYTES=$((SEEK_SECONDS * BITRATE_MBPS * 125000 + 1048576))
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/video-thumb.XXXXXX")
trap 'rm -f -- "$WORK_DIR/input.mp4" "$WORK_DIR/thumb.jpg" "$WORK_DIR/errors.log"; rmdir "$WORK_DIR"' EXIT
make_thumbnail() {
rm -f -- "$WORK_DIR/thumb.jpg"
ffmpeg -nostdin -y -hide_banner -loglevel error -xerror -threads 1 -filter_threads 1 \
-ss "$TIMESTAMP" -i "$WORK_DIR/input.mp4" -map 0:v:0 \
-frames:v 1 -q:v 2 -c:v mjpeg -threads 1 -update 1 -f image2 "$WORK_DIR/thumb.jpg" \
2>"$WORK_DIR/errors.log" &&
[[ -s "$WORK_DIR/thumb.jpg" && ! -s "$WORK_DIR/errors.log" ]]
}
if curl --disable -fsSL --globoff --retry 3 --connect-timeout 10 --max-time 60 \
--proto '=http,https' --proto-redir '=http,https' \
--range "0-$((BYTES - 1))" --max-filesize "$BYTES" \
--write-out 'HTTP %{http_code}; downloaded %{size_download} bytes\n' \
-o "$WORK_DIR/input.mp4" -- "$VIDEO_URL" && make_thumbnail; then
echo "Thumbnail extracted from the initial download"
else
echo "Retrying with a complete, seekable download" >&2
curl --disable -fsSL --globoff --retry 3 --connect-timeout 10 --max-time 60 \
--proto '=http,https' --proto-redir '=http,https' \
--write-out 'HTTP %{http_code}; downloaded %{size_download} bytes\n' \
-o "$WORK_DIR/input.mp4" -- "$VIDEO_URL"
if ! make_thumbnail; then
cat -- "$WORK_DIR/errors.log" >&2
echo "No thumbnail decoded at $TIMESTAMP" >&2
exit 1
fi
fi
# Refuse to replace a file created while the download was running.
(set -o noclobber; cat "$WORK_DIR/thumb.jpg" > "$OUT")
echo "Thumbnail saved to $OUT"
El script rechaza cualquier archivo o enlace simbólico existente antes de descargar y vuelve a
comprobarlo al publicar el resultado con noclobber de Bash. Elige un nombre de
archivo nuevo para repetir una extracción. Los archivos temporales de entrada e imagen se eliminan
al salir. La ruta de salida final se usa literalmente, incluidos los espacios y %.
Verifica el fotograma solicitado
Guarda esto como video-thumbnail-demo/verify.mts. Decodifica el fotograma de índice 30 del video original
de 20 fps, que comienza a los 1,500 segundos, y compara con él los tres JPEG decodificados. La
tolerancia admite diferencias debidas a la compresión JPEG; una imagen del primer fotograma rojo
no supera esta comparación.
import { execFileSync } from 'node:child_process'
function pixels(input: string, filter: string) {
return execFileSync('ffmpeg', [
'-nostdin', '-v', 'error', '-xerror', '-threads', '1', '-i', input,
'-vf', filter, '-filter_threads', '1', '-frames:v', '1',
'-threads', '1', '-pix_fmt', 'rgb24', '-f', 'rawvideo', 'pipe:1',
], { maxBuffer: 2 * 1024 * 1024 })
}
const reference = pixels('fast.mp4', 'select=eq(n\\,30)')
if (reference.length !== 640 * 360 * 3) throw new Error('Unexpected reference dimensions')
const patch = (20 * 640 + 20) * 3
if (reference[patch] > 10 || reference[patch + 1] > 10 || reference[patch + 2] < 240) {
throw new Error('Expected a blue square at 1.500 seconds')
}
for (const filename of ['thumb-fast.jpg', 'thumb-tail.jpg', 'thumb-ignore.jpg']) {
const actual = pixels(filename, 'null')
if (actual.length !== reference.length) throw new Error(`${filename}: wrong dimensions`)
let difference = 0
for (let index = 0; index < reference.length; index++) {
difference += Math.abs(actual[index] - reference[index])
}
if (difference / reference.length > 5) throw new Error(`${filename}: wrong frame`)
console.log(`${filename}: frame at 1.500 s verified`)
}
Guarda esto como video-thumbnail-demo/run-demo.sh. Inicia el servidor de origen, ejecuta el script auxiliar
por las tres rutas, verifica los resultados y detiene el servidor. Los videos, los JPEG y
origin.log se conservan para su inspección; origin.url se elimina
cuando el servidor se detiene.
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "$0")"
if [[ -e origin.url || -L origin.url ]]; then
echo "origin.url already exists; check whether a demo origin is still running" >&2
exit 1
fi
node origin.mts >origin.log 2>&1 &
ORIGIN_PID=$!
trap 'kill "$ORIGIN_PID" 2>/dev/null || true; wait "$ORIGIN_PID" 2>/dev/null || true' EXIT
for ((attempt = 0; attempt < 50; attempt++)); do
if [[ -s origin.url ]]; then break; fi
if ! kill -0 "$ORIGIN_PID" 2>/dev/null; then cat origin.log >&2; exit 1; fi
sleep 0.1
done
if [[ ! -s origin.url ]]; then echo "Origin did not start" >&2; exit 1; fi
BASE_URL=$(cat origin.url)
bash ./thumbnail.sh "$BASE_URL/fast.mp4" 00:00:01.500 1 thumb-fast.jpg
bash ./thumbnail.sh "$BASE_URL/tail.mp4" 00:00:01.500 1 thumb-tail.jpg
bash ./thumbnail.sh "$BASE_URL/ignore.mp4" 00:00:01.500 1 thumb-ignore.jpg
node verify.mts
Desde el mismo directorio donde ejecutaste la configuración, ejecuta:
bash video-thumbnail-demo/run-demo.sh
Después de los mensajes de descarga y de recurso a la descarga completa, el verificador imprime:
thumb-fast.jpg: frame at 1.500 s verified
thumb-tail.jpg: frame at 1.500 s verified
thumb-ignore.jpg: frame at 1.500 s verified
Abre los JPEG para ver el cuadrado azul y el patrón en movimiento en el instante seleccionado.
La primera descarga de fast.mp4 debería devolver 206
y 1.298.576 bytes. La copia con metadatos al final necesita una descarga completa.
El rango ignorado devuelve 200; el tamaño total anunciado supera el límite
del prefijo, por lo que cURL lo rechaza y el script vuelve a intentarlo sin un rango. Las rutas de
descarga completa transfieren todo el video, además de los bytes recibidos previamente. Para
volver a ejecutar el ejemplo, primero mueve sus tres JPEG a otro lugar.
Comprende las solicitudes de rango HTTP con cURL
Una solicitud de rango HTTP pide una porción de bytes en lugar del objeto completo. Los servidores
pueden anunciar Accept-Ranges: bytes, pero lo que importa es la respuesta a una solicitud GET
real de rango. Busca 206 Partial Content y un Content-Range coincidente en
origin.log o en los encabezados de respuesta.
Los extremos de los rangos de cURL son inclusivos: 0-1048575
solicita el primer 1 MiB. Un servidor puede ignorar el rango y devolver 200 OK
con el cuerpo completo. cURL no convierte automáticamente esa respuesta en una descarga parcial.
El script imprime el estado HTTP final y la cantidad de bytes del cuerpo descargados,
sin incluir los encabezados de respuesta ni la sobrecarga de transporte. El límite del prefijo usa
--max-filesize; cURL 8.4.0 añadió la aplicación de este
límite a respuestas cuyo tamaño no se conoce de antemano. La descarga completa de respaldo no tiene
límite de tamaño, así que reserva espacio en disco y ancho de banda para toda la entrada.
Calcula un rango de bytes adecuado
La cantidad de bytes que necesitas depende de la tasa de bits, la marca de tiempo, los metadatos
del contenedor y el fotograma clave cercano a la posición a la que te desplazas. Una estimación
rápida es bytes ≈ seconds × bitrate(B/s) + buffer.
Por ejemplo, 5 Mb/s equivalen aproximadamente a 625.000 B/s; 10 segundos más un búfer de 1 MiB dan
7.298.576 bytes. El script añade un segundo extra antes de aplicar esta estimación y pasa la
marca de tiempo fraccionaria original a FFmpeg.
Esto es solo una estimación: una tasa de bits variable y los metadatos al final pueden invalidarla. El video de ejemplo sin pérdida supera intencionalmente la estimación proporcionada de 1 Mb/s. Su fotograma cercano al inicio cabe en el prefijo, mientras que el archivo con metadatos al final no. Una descarga completa puede ser más rápida que varias solicitudes de prefijo fallidas; mide los resultados con tus propios archivos en lugar de dar por hecho el ahorro.
Envía datos parciales de video a FFmpeg mediante un pipe
FFmpeg puede leer la entrada estándar mediante pipe:0, pero un pipe no permite
retroceder. La entrada MP4 debe admitir la lectura secuencial, normalmente con sus metadatos
moov al inicio. La opción +faststart
de FFmpeg mueve esos metadatos al principio. Un prefijo más grande no resuelve el problema de los
metadatos al final, a menos que incluya el archivo entero. WebM y MKV también necesitan sus
encabezados de pista y suficientes datos multimedia para llegar al fotograma deseado.
Puedes conectar cURL con FFmpeg mediante un pipe y colocar -ss después de
-i para decodificar hacia adelante hasta el instante elegido. FFmpeg puede
cerrar el pipe en cuanto obtiene el fotograma, lo que provoca que cURL informe de un error de
escritura (23). Con pipefail, eso hace que el pipeline falle aunque se haya
producido una imagen. El script descarga los datos en un archivo temporal para evitar esta
ambigüedad. Al colocar
-ss antes de -i,
usa el desplazamiento en la entrada, con el desplazamiento preciso habilitado de forma
predeterminada durante la transcodificación.
Resuelve problemas comunes
- No se decodificó ninguna miniatura: Comprueba que la marca de tiempo esté dentro de la duración del video y que la URL devuelva datos de video. FFmpeg puede terminar correctamente sin producir un fotograma si la posición está más allá del final.
- Errores HTTP o de transferencia incompleta: Un prefijo fallido activa la descarga completa de respaldo. Si esa solicitud también falla, el script termina con un error sin publicar ninguna miniatura.
- Metadatos ausentes o entrada dañada: Forzar
-f mp4no puede reparar un archivo truncado. El script rechaza los diagnósticos de error del decodificador, pero no determina que todo el video de origen esté intacto: solo decodifica el fotograma solicitado. Verifica tanto el contenido de la imagen como su existencia. - Descargas completas inesperadas: Comprueba la respuesta real al rango y la estructura MP4.
--compressednegocia la compresión del cuerpo de la respuesta, no de los encabezados, y generalmente no ayuda con videos comprimidos. - La salida ya existe: Elige un nombre de archivo nuevo. Un JPEG anterior que se haya conservado no demuestra que la última extracción haya sido exitosa.
Para un flujo de trabajo de miniaturas alojado, consulta el Robot /video/thumbs.
