Extrair miniaturas de vídeo com cURL e FFmpeg
Extrair miniaturas de vídeos remotos pode exigir o download de arquivos grandes. Para arquivos cujos metadados e quadros solicitados estão perto do início, uma requisição de intervalo de bytes pode reduzir esse download. Este guia fornece um script auxiliar em Bash que tenta baixar um prefixo e, se necessário, recorre a um download completo que permite saltar para um ponto do vídeo. Um vídeo local com movimento permite verificar se o JPEG contém o quadro solicitado.
Configurar um vídeo HTTP conhecido
Use Linux com Bash, GNU coreutils, cURL 8.5 ou posterior, FFmpeg com ffprobe e o codificador libx264,
e Node.js. O Ubuntu 24.04 fornece as ferramentas de shell e mídia pelos pacotes bash,
coreutils, curl e ffmpeg. Para o servidor de origem local
e o verificador, use Node.js 24.15 ou posterior da versão 24, ou a versão 26.
Os arquivos .mts abaixo usam o suporte nativo a TypeScript
do Node e utilizam apenas módulos integrados. Não é necessário instalar um projeto. Os binários
oficiais do Node.js 26 para Linux também exigem o ambiente de execução libatomic,
disponível no pacote libatomic1 do Ubuntu. O exemplo funciona com FFmpeg 6.1.1 e
cURL 8.5.0 do Ubuntu, assim como com FFmpeg 9.0.1 e cURL 8.22.0.
Cole este bloco no Bash a partir de um diretório em que video-thumbnail-demo não exista.
Ele cria um vídeo de teste de 12 segundos, com resolução de 640×360 e 20 quadros por segundo, e uma
cópia com os metadados MP4 no final. O padrão se move; o quadrado no canto superior esquerdo é
vermelho antes de 1 segundo e azul depois.
(
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
)
Se a geração falhar, o bloco remove os vídeos incompletos e o diretório que criou. Corrija a ausência da ferramenta ou do codificador e execute-o novamente. Um diretório existente é preservado. Os parênteses mantêm as opções do shell e seu diretório de trabalho inalterados.
Salve o conteúdo a seguir como video-thumbnail-demo/origin.mts. Este pequeno servidor de loopback
serve os dois arquivos, respeita intervalos de prefixo e os ignora deliberadamente em /ignore.mp4.
Ele escolhe uma porta disponível e registra a URL em origin.url.
É um recurso de teste para este tutorial, não um 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 })
Automatizar o fluxo de trabalho com um script auxiliar em Bash
Salve este conteúdo como video-thumbnail-demo/thumbnail.sh. Ele aceita HH:MM:SS[.ms],
uma taxa de bits estimada em número inteiro de 1 a 999 Mb/s e um caminho de saída. O arquivo de
entrada temporário permite saltar para um ponto do vídeo e recorrer ao download completo. Uma falha
do decodificador ou uma tentativa de posicionamento além da duração do vídeo não deve contar como
sucesso apenas porque o FFmpeg retornou zero: o script também exige uma imagem recém-gravada e não
vazia.
#!/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"
O script recusa um arquivo ou link simbólico existente antes do download e verifica novamente ao
publicar a imagem, usando noclobber do Bash. Escolha um novo nome de arquivo
para repetir uma extração. Os arquivos temporários de entrada e de imagem são removidos ao encerrar.
O caminho de saída final é usado literalmente, incluindo espaços e %.
Verificar o quadro solicitado
Salve este conteúdo como video-thumbnail-demo/verify.mts. Ele decodifica o quadro de índice 30 do
vídeo original de 20 fps, que começa em 1,500 segundos, e compara os três JPEGs decodificados com
esse quadro. A tolerância permite diferenças de compressão JPEG; uma imagem do primeiro quadro
vermelho não passa nessa comparação.
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`)
}
Salve este conteúdo como video-thumbnail-demo/run-demo.sh. Ele inicia o servidor de origem, executa o
script auxiliar nos três cenários, verifica os resultados e encerra o servidor de origem. Os vídeos,
os JPEGs e origin.log permanecem para inspeção; origin.url
é removido quando o servidor é encerrado.
#!/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
No mesmo diretório em que você executou a configuração, execute:
bash video-thumbnail-demo/run-demo.sh
Após as mensagens de download e de recurso ao download completo, o 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
Abra os JPEGs para ver o quadrado azul e o padrão em movimento no instante selecionado. O primeiro
download de fast.mp4 deve retornar 206 e 1.298.576 bytes.
A cópia com metadados no final precisa de um download completo. O intervalo ignorado retorna
200; o tamanho total informado excede o limite do prefixo, então o cURL
recusa o download e o script tenta novamente sem um intervalo. Os cenários de download completo
transferem o vídeo inteiro, além de quaisquer bytes recebidos anteriormente. Para executar a
demonstração novamente, primeiro mova os três JPEGs para outro local.
Entender requisições de intervalo HTTP com cURL
Uma requisição de intervalo HTTP solicita uma faixa de bytes em vez do objeto completo. Os
servidores podem anunciar Accept-Ranges: bytes, mas o que importa é a resposta a uma
requisição GET de intervalo real. Procure 206 Partial Content e um
Content-Range correspondente em origin.log ou nos cabeçalhos da resposta.
Os limites de intervalo do cURL são inclusivos: 0-1048575
solicita o primeiro 1 MiB. Um servidor pode ignorar o intervalo e retornar 200 OK
com o corpo completo. O cURL não transforma isso automaticamente em um download parcial.
O script imprime o status HTTP final e a contagem de bytes baixados do corpo,
excluindo os cabeçalhos da resposta e a sobrecarga de transporte. O limite do prefixo usa
--max-filesize; o cURL 8.4.0 passou a aplicar esse limite
às respostas cujo tamanho não é conhecido de antemão. O download completo usado como alternativa
não tem limite de tamanho, então reserve espaço em disco e largura de banda para toda a entrada.
Calcular um intervalo de bytes adequado
A quantidade de bytes necessária depende da taxa de bits, do instante, dos metadados do contêiner
e do quadro-chave próximo à posição desejada. Uma estimativa rápida é bytes ≈ seconds × bitrate(B/s) + buffer.
Por exemplo, 5 Mb/s equivalem a aproximadamente 625.000 B/s; 10 segundos mais um buffer de 1 MiB
resultam em 7.298.576 bytes. O script acrescenta um segundo antes de aplicar essa estimativa e passa
o instante fracionário original para o FFmpeg.
Isso é apenas uma estimativa: a taxa de bits variável e os metadados no final podem invalidá-la. O vídeo de demonstração sem perdas excede intencionalmente a estimativa fornecida de 1 Mb/s. Seu quadro próximo ao início cabe no prefixo, mas o arquivo com metadados no final não. Um download completo pode ser mais rápido que várias requisições de prefixo malsucedidas; faça medições com seus próprios arquivos em vez de presumir uma economia.
Passar dados parciais de vídeo ao FFmpeg por um pipe
O FFmpeg pode ler a entrada padrão por pipe:0, mas um pipe não permite
voltar a uma posição anterior. A entrada MP4 deve permitir leitura sequencial, geralmente com os
metadados moov no início. A
opção +faststart do FFmpeg move esses metadados
para o início. Um prefixo maior não resolve o problema de metadados no final, a menos que inclua o
arquivo inteiro. WebM e MKV também precisam dos cabeçalhos das trilhas e de dados de mídia
suficientes para alcançar o quadro desejado.
Você pode conectar o cURL ao FFmpeg por um pipe e colocar -ss depois de
-i para decodificar até o instante escolhido. O FFmpeg pode fechar o
pipe assim que obtiver o quadro, fazendo o cURL relatar um erro de gravação (23).
Com pipefail, isso faz o pipeline falhar mesmo que uma imagem tenha sido
produzida. O script baixa os dados para um arquivo temporário para evitar essa ambiguidade. O uso de
-ss antes de -i
permite o posicionamento na entrada, com posicionamento preciso habilitado por padrão durante a
transcodificação.
Solucionar problemas comuns
- Nenhuma miniatura decodificada: Verifique se o instante está dentro da duração do vídeo e se a URL retorna dados de vídeo. O FFmpeg pode encerrar com sucesso sem produzir um quadro além do final.
- Erros HTTP ou de transferência incompleta: Uma falha no prefixo aciona a tentativa de download completo. Se essa requisição também falhar, o script encerra com falha sem publicar uma miniatura.
- Metadados ausentes ou entrada danificada: Forçar
-f mp4não repara um arquivo truncado. O script rejeita diagnósticos de erro do decodificador, mas não comprova que todo o vídeo de origem está intacto: ele decodifica apenas o quadro solicitado. Verifique o conteúdo da imagem, além de sua existência. - Downloads completos inesperados: Verifique a resposta real ao intervalo e a organização do MP4.
--compressednegocia a compressão do corpo da resposta, não dos cabeçalhos, e geralmente não ajuda com vídeo comprimido. - A saída já existe: Escolha um novo nome de arquivo. Um JPEG anterior preservado não é evidência de que a extração mais recente teve sucesso.
Para um fluxo de trabalho hospedado de miniaturas, consulte o Robot /video/thumbs.
